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
+25 -7
View File
@@ -271,13 +271,13 @@ Architectural rules fixed for this service:
* `User Service` stores only the current effective `declared_country`; review
workflow and history belong to `Geo Profile Service`.
* During the current auth-registration rollout, `Auth / Session Service`
passes temporary `preferred_language="en"` plus the confirmed `time_zone`
into `User Service`. Gateway-side geoip language derivation is a later
rollout step and is not part of the current source-of-truth contract.
passes a preferred-language candidate derived from public
`Accept-Language`, falling back to `en` when no supported value is
available, plus the confirmed `time_zone` into `User Service`.
Future billing does not become a direct dependency of other services. `Billing Service` will feed entitlement/payment outcomes into `User Service`, and the rest of the platform will continue to use `User Service` as the source of truth for current entitlements.
## 4. Mail Service
## 4. [Mail Service](mail/README.md)
`Mail Service` is the internal email delivery service.
@@ -286,7 +286,23 @@ Split of responsibility:
* auth code emails: `Auth / Session Service -> Mail Service` directly;
* all other user/admin notification emails: `Notification Service -> Mail Service`.
Mail delivery may be internally queued inside the mail service, but to its callers it is still a synchronous internal command where the caller needs a deterministic send-or-fail result.
Transport rules:
* `Auth / Session Service -> Mail Service` uses the dedicated synchronous
trusted internal REST contract `POST /api/v1/internal/login-code-deliveries`;
* `Notification Service -> Mail Service` is an asynchronous internal command
flow carried through the event bus or an equivalent queue-backed handoff.
`Mail Service` may internally queue both flows.
Its trusted operator read and resend APIs are part of the v1 service surface,
not a later add-on.
For auth callers, a successful result means the request was durably accepted
into the mail-delivery pipeline or intentionally suppressed; it does not
require that the external SMTP exchange already completed before the response
is returned.
Stable service-local delivery rules, retry semantics, and Redis-backed
processing details belong in [`mail/README.md`](mail/README.md), not in the
root architecture document.
## 5. [Geo Profile Service](geoprofile/README.md)
@@ -297,7 +313,7 @@ It integrates with:
* gateway as asynchronous ingest producer;
* `User Service` for current effective `declared_country`;
* `Auth / Session Service` for suspicious session blocking;
* `Mail Service` only for optional admin notifications.
* `Notification Service` for optional admin notifications.
It owns:
@@ -309,6 +325,8 @@ It owns:
It does not block the request that triggered suspicion.
It can only request block of suspicious sessions for subsequent requests.
It does not call `Mail Service` directly; optional admin mail must flow
through `Notification Service`.
In this document, references to `Edge Service` in older geo documentation should be understood as `Edge Gateway`.
@@ -519,7 +537,7 @@ It has a deliberately minimal role:
* decide whether a given event should result in push, email, or both;
* render and route notification payloads;
* send push-targeted events toward gateway;
* send email-targeted commands toward `Mail Service`.
* send email-targeted asynchronous commands toward `Mail Service`.
It is not a source of truth for user preferences in v1 unless a later feature requires it.
+26 -5
View File
@@ -84,7 +84,8 @@ The service is not responsible for:
- downstream business authorization
- direct push delivery to clients
- long-lived hot-path session caching inside gateway
- mail-service implementation details beyond the mail-delivery contract
- mail-service implementation details beyond the dedicated login-code delivery
REST contract
## Position in the System
@@ -140,15 +141,23 @@ The effective DTO contract is:
| `POST /api/v1/public/auth/send-email-code` | `{ "email": string }` | `{ "challenge_id": string }` |
| `POST /api/v1/public/auth/confirm-email-code` | `{ "challenge_id": string, "code": string, "client_public_key": string, "time_zone": string }` | `{ "device_session_id": string }` |
`send-email-code` may additionally receive the optional public
`Accept-Language` header through gateway. Auth resolves the first supported
BCP 47 language tag from that header, falls back to `en` when no supported
value is available, uses the resolved value as the auth-mail locale for the
dedicated `Mail Service` REST contract, and stores it on the challenge as the
create-only preferred-language candidate for a later first-user ensure step.
The created `challenge_id` is sent to `Mail Service` as the raw
`Idempotency-Key` header value of that dedicated REST call.
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key registered for the created device session.
`time_zone` is the client-selected IANA time zone name. During the current
rollout phase, successful confirms forward create-only user registration
context to `User Service` as `preferred_language="en"` and the supplied
`time_zone` until gateway geoip-based language derivation is deployed.
context to `User Service` as the stored preferred-language candidate from
`send-email-code` and the supplied `time_zone`.
`User Service` now validates `preferred_language` as BCP 47 and canonicalizes
the stored value on creation, so any future derived language must already be a
valid BCP 47 tag before auth forwards it.
the stored value on creation, so the derived public language value must
already be a valid BCP 47 tag before auth forwards it.
Public boundary rules:
@@ -162,6 +171,9 @@ Public boundary rules:
IANA time zone name
- `send-email-code` remains success-shaped for existing, new, blocked, and
throttled e-mail paths
- `send-email-code` may use optional public `Accept-Language` to derive and
store the auth-mail locale plus future create-only `preferred_language`
candidate; unsupported or missing values fall back to `en`
- `confirm-email-code` returns a ready `device_session_id` synchronously on
success
@@ -236,6 +248,7 @@ Core fields:
- creation and expiration timestamps
- send and confirm attempt counters
- minimal abuse metadata
- stored preferred-language candidate derived at send time
- optional confirmation metadata used for idempotent retry
### Challenge States
@@ -259,6 +272,14 @@ Supported `challenge.DeliveryState` values:
- `throttled`
- `failed`
For the dedicated `Mail Service` REST contract, `delivery_state=sent` means
auth successfully handed the request off to
`POST /api/v1/internal/login-code-deliveries` and the mail-delivery pipeline.
That call uses the created `challenge_id` as the raw `Idempotency-Key` header
value.
It does not require that the SMTP provider exchange already completed before
`challenge_id` was returned to the caller.
Policy rules:
- initial challenge TTL is `5m`
+20
View File
@@ -36,7 +36,15 @@ paths:
Accepts one client e-mail address and starts the public challenge flow.
The outward result remains success-shaped even when the underlying
policy suppresses mail delivery for anti-enumeration purposes.
The JSON body stays unchanged. Gateway may additionally forward the
optional public `Accept-Language` header so auth can derive the
auth-mail locale and the create-only preferred-language candidate used
later during first-user creation. Missing or unsupported values fall
back to `en`.
security: []
parameters:
- $ref: "#/components/parameters/AcceptLanguage"
requestBody:
required: true
content:
@@ -111,6 +119,18 @@ paths:
"503":
$ref: "#/components/responses/ServiceUnavailableError"
components:
parameters:
AcceptLanguage:
name: Accept-Language
in: header
required: false
description: |
Optional RFC 9110 `Accept-Language` header forwarded by gateway so
auth can derive the auth-mail locale and create-only
preferred-language candidate. The first supported BCP 47 tag wins;
unsupported or missing values fall back to `en`.
schema:
type: string
schemas:
SendEmailCodeRequest:
type: object
+52
View File
@@ -62,12 +62,37 @@ func TestPublicOpenAPISpecMatchesGatewayPublicAuthContract(t *testing.T) {
responseSchemaRef(t, gatewayOperation, http.StatusOK),
"path "+path+" success response schema",
)
compareParameterRefs(
t,
authOperation.Parameters,
gatewayOperation.Parameters,
"path "+path+" parameters",
)
for _, status := range publicErrorStatuses(path) {
assertSchemaRef(t, responseSchemaRef(t, authOperation, status), errorResponseRef, "path "+path+" error response "+http.StatusText(status)+" envelope")
}
}
assertOperationParameterRefs(
t,
getOperation(t, authDoc, "/api/v1/public/auth/send-email-code", http.MethodPost),
"#/components/parameters/AcceptLanguage",
)
assertOperationParameterRefs(
t,
getOperation(t, gatewayDoc, "/api/v1/public/auth/send-email-code", http.MethodPost),
"#/components/parameters/AcceptLanguage",
)
assertOperationParameterRefs(
t,
getOperation(t, authDoc, "/api/v1/public/auth/confirm-email-code", http.MethodPost),
)
assertOperationParameterRefs(
t,
getOperation(t, gatewayDoc, "/api/v1/public/auth/confirm-email-code", http.MethodPost),
)
compareSchemaRefs(
t,
authErrorEnvelope,
@@ -352,6 +377,16 @@ func compareSchemaRefs(t *testing.T, got *openapi3.SchemaRef, want *openapi3.Sch
}
}
func compareParameterRefs(t *testing.T, got openapi3.Parameters, want openapi3.Parameters, name string) {
t.Helper()
gotJSON := mustJSON(t, got)
wantJSON := mustJSON(t, want)
if !bytes.Equal(gotJSON, wantJSON) {
require.Failf(t, "test failed", "%s mismatch:\n got: %s\nwant: %s", name, gotJSON, wantJSON)
}
}
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) {
t.Helper()
@@ -360,6 +395,23 @@ func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, n
}
}
func assertOperationParameterRefs(t *testing.T, operation *openapi3.Operation, refs ...string) {
t.Helper()
if len(operation.Parameters) != len(refs) {
require.Failf(t, "test failed", "operation parameter count = %d, want %d", len(operation.Parameters), len(refs))
}
for index, want := range refs {
if operation.Parameters[index] == nil {
require.Failf(t, "test failed", "operation parameter %d is nil", index)
}
if operation.Parameters[index].Ref != want {
require.Failf(t, "test failed", "operation parameter %d ref = %q, want %q", index, operation.Parameters[index].Ref, want)
}
}
}
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
t.Helper()
+13 -6
View File
@@ -9,14 +9,14 @@ sequenceDiagram
participant Auth
participant Abuse as Resend throttle
participant User as UserDirectory
participant Mail as MailSender
participant Mail as Mail Service REST
participant Challenge as ChallengeStore
participant Session as SessionStore
participant Config as ConfigProvider
participant Projection as Gateway projection publisher
Client->>Gateway: POST /api/v1/public/auth/send-email-code
Gateway->>Auth: POST /api/v1/public/auth/send-email-code
Client->>Gateway: POST /api/v1/public/auth/send-email-code + Accept-Language
Gateway->>Auth: POST /api/v1/public/auth/send-email-code + Accept-Language
Auth->>Abuse: check and reserve cooldown
alt throttled
Abuse-->>Auth: throttled
@@ -30,8 +30,8 @@ sequenceDiagram
alt blocked
Auth->>Challenge: mark delivery_suppressed
else not blocked
Auth->>Mail: SendLoginCode(email, code)
Mail-->>Auth: sent / suppressed / failure
Auth->>Mail: POST /api/v1/internal/login-code-deliveries + Idempotency-Key=challenge_id
Mail-->>Auth: 200 {outcome=sent|suppressed} / 503
Auth->>Challenge: persist final delivery outcome
end
Auth-->>Gateway: 200 {challenge_id}
@@ -40,7 +40,7 @@ sequenceDiagram
Client->>Gateway: POST /api/v1/public/auth/confirm-email-code
Gateway->>Auth: POST /api/v1/public/auth/confirm-email-code
Auth->>Challenge: load and validate challenge
Auth->>User: EnsureUserByEmail(email, registration_context)
Auth->>User: EnsureUserByEmail(email, stored preferred_language + time_zone)
User-->>Auth: existing / created / blocked
Auth->>Config: LoadSessionLimit()
Auth->>Session: CountActiveByUserID(user_id)
@@ -51,6 +51,13 @@ sequenceDiagram
Auth-->>Gateway: 200 {device_session_id}
```
Auth uses the dedicated trusted `Mail Service` REST route
`POST /api/v1/internal/login-code-deliveries`.
It sends the created `challenge_id` as the raw `Idempotency-Key` header
value.
For this boundary, `sent` means durable acceptance into the mail-delivery
pipeline; SMTP completion may still happen later in `Mail Service` workers.
## Revoke and Block Flow
```mermaid
+21
View File
@@ -659,9 +659,21 @@ type gatewayCompatibilityHTTPResponse struct {
func gatewayCompatibilityPostJSON(t *testing.T, url string, body string) gatewayCompatibilityHTTPResponse {
t.Helper()
return gatewayCompatibilityPostJSONWithHeaders(t, url, body, nil)
}
func gatewayCompatibilityPostJSONWithHeaders(t *testing.T, url string, body string, headers map[string]string) gatewayCompatibilityHTTPResponse {
t.Helper()
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if strings.TrimSpace(value) == "" {
continue
}
request.Header.Set(key, value)
}
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
@@ -685,6 +697,15 @@ func gatewayCompatibilityPostJSONValue(t *testing.T, url string, value any) gate
return gatewayCompatibilityPostJSON(t, url, string(payload))
}
func gatewayCompatibilityPostJSONValueWithHeaders(t *testing.T, url string, value any, headers map[string]string) gatewayCompatibilityHTTPResponse {
t.Helper()
payload, err := json.Marshal(value)
require.NoError(t, err)
return gatewayCompatibilityPostJSONWithHeaders(t, url, string(payload), headers)
}
func gatewayCompatibilityActiveSession(
t *testing.T,
deviceSessionID string,
+1 -1
View File
@@ -22,6 +22,7 @@ require (
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.49.0
golang.org/x/text v0.36.0
)
require (
@@ -75,7 +76,6 @@ require (
golang.org/x/arch v0.25.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
+1 -2
View File
@@ -152,8 +152,7 @@ golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
@@ -152,6 +152,7 @@ func contractPendingChallenge(now time.Time) challenge.Challenge {
ID: common.ChallengeID("challenge-pending"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-pending-code"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: now,
@@ -179,6 +180,7 @@ func contractConfirmedChallenge(t *testing.T, now time.Time) challenge.Challenge
ID: common.ChallengeID("challenge-confirmed"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-code"),
PreferredLanguage: "en",
Status: challenge.StatusConfirmedPendingExpire,
DeliveryState: challenge.DeliverySent,
CreatedAt: now,
@@ -95,9 +95,10 @@ func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCod
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
payload, statusCode, err := c.doRequest(ctx, "send login code", map[string]string{
payload, statusCode, err := c.doRequest(ctx, "send login code", input.IdempotencyKey, map[string]string{
"email": input.Email.String(),
"code": input.Code,
"locale": input.Locale,
})
if err != nil {
return ports.SendLoginCodeResult{}, err
@@ -121,7 +122,7 @@ func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCod
return result, nil
}
func (c *RESTClient) doRequest(ctx context.Context, operation string, requestBody any) ([]byte, int, error) {
func (c *RESTClient) doRequest(ctx context.Context, operation string, idempotencyKey string, requestBody any) ([]byte, int, error) {
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("%s: marshal request body: %w", operation, err)
@@ -135,6 +136,7 @@ func (c *RESTClient) doRequest(ctx context.Context, operation string, requestBod
return nil, 0, fmt.Errorf("%s: build request: %w", operation, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := c.httpClient.Do(request)
if err != nil {
@@ -128,7 +128,8 @@ func TestRESTClientSendLoginCodeSuccessCases(t *testing.T) {
assert.Equal(t, http.MethodPost, requests[0].Method)
assert.Equal(t, sendLoginCodePath, requests[0].Path)
assert.Equal(t, "application/json", requests[0].ContentType)
assert.JSONEq(t, `{"email":"pilot@example.com","code":"654321"}`, requests[0].Body)
assert.Equal(t, "challenge-1", requests[0].IdempotencyKey)
assert.JSONEq(t, `{"email":"pilot@example.com","code":"654321","locale":"en"}`, requests[0].Body)
})
}
}
@@ -136,9 +137,9 @@ func TestRESTClientSendLoginCodeSuccessCases(t *testing.T) {
func TestRESTClientPreservesNormalizedEmailAndCodeExactly(t *testing.T) {
t.Parallel()
var captured string
var captured capturedRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = captureRequest(t, r).Body
captured = captureRequest(t, r)
writeJSON(t, w, http.StatusOK, map[string]string{"outcome": "sent"})
}))
defer server.Close()
@@ -147,11 +148,14 @@ func TestRESTClientPreservesNormalizedEmailAndCodeExactly(t *testing.T) {
result, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("Pilot+Alias@Example.com"),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: "fr-FR",
})
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSent, result.Outcome)
assert.JSONEq(t, `{"email":"Pilot+Alias@Example.com","code":"123456"}`, captured)
assert.Equal(t, "challenge-1", captured.IdempotencyKey)
assert.JSONEq(t, `{"email":"Pilot+Alias@Example.com","code":"123456","locale":"fr-FR"}`, captured.Body)
}
func TestRESTClientSendLoginCodeDoesNotRetry(t *testing.T) {
@@ -312,7 +316,9 @@ func TestRESTClientContextAndValidation(t *testing.T) {
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email(" bad@example.com "),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: "en",
})
return err
},
@@ -322,7 +328,33 @@ func TestRESTClientContextAndValidation(t *testing.T) {
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: " 123456 ",
Locale: "en",
})
return err
},
},
{
name: "invalid locale",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: " en ",
})
return err
},
},
{
name: "invalid idempotency key",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: " challenge-1 ",
Code: "123456",
Locale: "en",
})
return err
},
@@ -343,6 +375,7 @@ type capturedRequest struct {
Method string
Path string
ContentType string
IdempotencyKey string
Body string
}
@@ -356,6 +389,7 @@ func captureRequest(t *testing.T, request *http.Request) capturedRequest {
Method: request.Method,
Path: request.URL.Path,
ContentType: request.Header.Get("Content-Type"),
IdempotencyKey: request.Header.Get("Idempotency-Key"),
Body: strings.TrimSpace(string(body)),
}
}
@@ -60,7 +60,8 @@ func (step StubStep) Validate() error {
return nil
}
// Attempt records one validated delivery request handled by StubSender.
// Attempt records one validated delivery request handled by StubSender,
// including the auth challenge-derived idempotency key.
type Attempt struct {
// Input stores the validated cleartext mail-delivery request exactly as it
// was passed into SendLoginCode.
@@ -193,6 +193,8 @@ func TestStubSenderSendLoginCodeInvalidInput(t *testing.T) {
func validInput() ports.SendLoginCodeInput {
return ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
}
}
@@ -24,6 +24,8 @@ import (
const expirationGracePeriod = 5 * time.Minute
const defaultPreferredLanguage = "en"
// Config configures one Redis-backed challenge store instance.
type Config struct {
// Addr is the Redis network address in host:port form.
@@ -59,6 +61,7 @@ type redisRecord struct {
ChallengeID string `json:"challenge_id"`
Email string `json:"email"`
CodeHashBase64 string `json:"code_hash_base64"`
PreferredLanguage string `json:"preferred_language,omitempty"`
Status challenge.Status `json:"status"`
DeliveryState challenge.DeliveryState `json:"delivery_state"`
CreatedAt string `json:"created_at"`
@@ -291,6 +294,7 @@ func redisRecordFromChallenge(record challenge.Challenge) (redisRecord, error) {
ChallengeID: record.ID.String(),
Email: record.Email.String(),
CodeHashBase64: base64.StdEncoding.EncodeToString(record.CodeHash),
PreferredLanguage: record.PreferredLanguage,
Status: record.Status,
DeliveryState: record.DeliveryState,
CreatedAt: formatTimestamp(record.CreatedAt),
@@ -357,6 +361,7 @@ func challengeFromRedisRecord(stored redisRecord) (challenge.Challenge, error) {
ID: common.ChallengeID(stored.ChallengeID),
Email: common.Email(stored.Email),
CodeHash: codeHash,
PreferredLanguage: normalizeStoredPreferredLanguage(stored.PreferredLanguage),
Status: stored.Status,
DeliveryState: stored.DeliveryState,
CreatedAt: createdAt,
@@ -459,6 +464,15 @@ func formatOptionalTimestamp(value *time.Time) *string {
return &formatted
}
func normalizeStoredPreferredLanguage(value string) string {
preferredLanguage := strings.TrimSpace(value)
if preferredLanguage == "" {
return defaultPreferredLanguage
}
return preferredLanguage
}
func redisTTL(expiresAt time.Time) time.Duration {
ttl := time.Until(expiresAt.UTC())
if ttl < 0 {
@@ -454,6 +454,7 @@ func testPendingChallenge(now time.Time) challenge.Challenge {
ID: common.ChallengeID("challenge-pending"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-pending-code"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: now,
@@ -476,6 +477,7 @@ func testChallenge(now time.Time) challenge.Challenge {
ID: common.ChallengeID("challenge-confirmed"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-code"),
PreferredLanguage: "en",
Status: challenge.StatusConfirmedPendingExpire,
DeliveryState: challenge.DeliverySent,
CreatedAt: now,
@@ -495,6 +497,36 @@ func testChallenge(now time.Time) challenge.Challenge {
}
}
func TestStoreGetDefaultsMissingPreferredLanguageToEnglish(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := newTestStore(t, server, Config{})
now := time.Unix(1_775_130_250, 0).UTC()
record := testPendingChallenge(now)
stored, err := redisRecordFromChallenge(record)
require.NoError(t, err)
stored.PreferredLanguage = ""
payload := mustMarshalJSON(t, map[string]any{
"challenge_id": stored.ChallengeID,
"email": stored.Email,
"code_hash_base64": stored.CodeHashBase64,
"status": stored.Status,
"delivery_state": stored.DeliveryState,
"created_at": stored.CreatedAt,
"expires_at": stored.ExpiresAt,
"send_attempt_count": stored.SendAttemptCount,
"confirm_attempt_count": stored.ConfirmAttemptCount,
})
server.Set(store.lookupKey(record.ID), payload)
got, err := store.Get(context.Background(), record.ID)
require.NoError(t, err)
assert.Equal(t, "en", got.PreferredLanguage)
}
func timePointer(value time.Time) *time.Time {
return &value
}
@@ -275,6 +275,7 @@ type seedChallengeOptions struct {
Code string
Status challenge.Status
ExpiresAt time.Time
PreferredLanguage string
}
type endToEndApp struct {
@@ -315,11 +316,15 @@ func newEndToEndApp(t *testing.T, options endToEndOptions) endToEndApp {
ID: common.ChallengeID(options.SeedChallenge.ID),
Email: common.Email("pilot@example.com"),
CodeHash: mustHashCode(t, options.SeedChallenge.Code),
PreferredLanguage: options.SeedChallenge.PreferredLanguage,
Status: options.SeedChallenge.Status,
DeliveryState: deliveryStateForSeedChallenge(options.SeedChallenge.Status),
CreatedAt: now.Add(-time.Minute),
ExpiresAt: expiresAt,
}
if record.PreferredLanguage == "" {
record.PreferredLanguage = "en"
}
require.NoError(t, challengeStore.Create(context.Background(), record))
}
@@ -110,7 +110,10 @@ func handleSendEmailCode(useCase SendEmailCodeUseCase, timeout time.Duration) gi
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, sendemailcode.Input{Email: request.Email})
result, err := useCase.Execute(callCtx, sendemailcode.Input{
Email: request.Email,
AcceptLanguage: c.GetHeader("Accept-Language"),
})
if err != nil {
abortWithProjection(c, projectSendEmailCodeError(err))
return
@@ -25,7 +25,11 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
SendEmailCode: sendEmailCodeFunc(func(_ context.Context, input sendemailcode.Input) (sendemailcode.Result, error) {
assert.Equal(t, sendemailcode.Input{
Email: "pilot@example.com",
AcceptLanguage: "fr-FR, en;q=0.8",
}, input)
return sendemailcode.Result{ChallengeID: "challenge-123"}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
@@ -40,6 +44,7 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
bytes.NewBufferString(`{"email":" pilot@example.com "}`),
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept-Language", "fr-FR, en;q=0.8")
handler.ServeHTTP(recorder, request)
@@ -5,6 +5,7 @@ package challenge
import (
"errors"
"fmt"
"strings"
"time"
"galaxy/authsession/internal/domain/common"
@@ -239,6 +240,10 @@ type Challenge struct {
// CodeHash stores only the hashed confirmation code.
CodeHash []byte
// PreferredLanguage stores the canonical create-only preferred-language
// candidate derived when the challenge was created.
PreferredLanguage string
// Status reports the coarse challenge lifecycle state.
Status Status
@@ -279,6 +284,12 @@ func (c Challenge) Validate() error {
if len(c.CodeHash) == 0 {
return errors.New("challenge code hash must not be empty")
}
if strings.TrimSpace(c.PreferredLanguage) == "" {
return errors.New("challenge preferred language must not be empty")
}
if strings.TrimSpace(c.PreferredLanguage) != c.PreferredLanguage {
return errors.New("challenge preferred language must not contain surrounding whitespace")
}
if !c.Status.IsKnown() {
return fmt.Errorf("challenge status %q is unsupported", c.Status)
}
@@ -407,6 +407,7 @@ func validChallenge(t *testing.T) Challenge {
ID: common.ChallengeID("challenge-123"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash-123"),
PreferredLanguage: "en",
Status: StatusPendingSend,
DeliveryState: DeliveryPending,
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
+16
View File
@@ -24,8 +24,16 @@ type SendLoginCodeInput struct {
// Email identifies the normalized target e-mail address.
Email common.Email
// IdempotencyKey stores the raw challenge_id value sent to Mail Service as
// the required Idempotency-Key header.
IdempotencyKey string
// Code stores the cleartext login code that should be delivered to Email.
Code string
// Locale stores the canonical BCP 47 language tag that selects the auth
// mail template locale.
Locale string
}
// Validate reports whether SendLoginCodeInput contains a complete delivery
@@ -35,10 +43,18 @@ func (i SendLoginCodeInput) Validate() error {
return fmt.Errorf("send login code input email: %w", err)
}
switch {
case strings.TrimSpace(i.IdempotencyKey) == "":
return errors.New("send login code input idempotency key must not be empty")
case strings.TrimSpace(i.IdempotencyKey) != i.IdempotencyKey:
return errors.New("send login code input idempotency key must not contain surrounding whitespace")
case strings.TrimSpace(i.Code) == "":
return errors.New("send login code input code must not be empty")
case strings.TrimSpace(i.Code) != i.Code:
return errors.New("send login code input code must not contain surrounding whitespace")
case strings.TrimSpace(i.Locale) == "":
return errors.New("send login code input locale must not be empty")
case strings.TrimSpace(i.Locale) != i.Locale:
return errors.New("send login code input locale must not contain surrounding whitespace")
default:
return nil
}
+3
View File
@@ -311,7 +311,9 @@ func TestSendLoginCodeInputAndResultValidate(t *testing.T) {
input := SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
}
if err := input.Validate(); err != nil {
require.Failf(t, "test failed", "SendLoginCodeInput.Validate() returned error: %v", err)
@@ -342,6 +344,7 @@ func challengeFixture() challenge.Challenge {
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
@@ -22,7 +22,6 @@ const (
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
defaultPreferredLanguage = "en"
)
// Input describes one public confirm-email-code request.
@@ -249,7 +248,7 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, ports.EnsureUserInput{
Email: current.Email,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: defaultPreferredLanguage,
PreferredLanguage: shared.ResolvePreferredLanguage(current.PreferredLanguage),
TimeZone: timeZone,
},
})
@@ -70,7 +70,12 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
if err := deps.userDirectory.QueueCreatedUserIDs(common.UserID("user-created")); err != nil {
require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
record.PreferredLanguage = "fr-FR"
if err := record.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
require.Failf(t, "test failed", "Create() returned error: %v", err)
}
@@ -88,12 +93,12 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
require.Failf(t, "test failed", "Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
}
record, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
session, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
if err != nil {
require.Failf(t, "test failed", "Get() returned error: %v", err)
}
if record.UserID != common.UserID("user-created") {
require.Failf(t, "test failed", "session user id = %q, want %q", record.UserID, common.UserID("user-created"))
if session.UserID != common.UserID("user-created") {
require.Failf(t, "test failed", "session user id = %q, want %q", session.UserID, common.UserID("user-created"))
}
}
@@ -556,7 +561,12 @@ func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) {
if err := recordingDirectory.delegate.QueueCreatedUserIDs(common.UserID("user-created")); err != nil {
require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
record.PreferredLanguage = "fr-FR"
if err := record.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
require.Failf(t, "test failed", "Create() returned error: %v", err)
}
@@ -589,8 +599,8 @@ func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) {
if recordingDirectory.lastEnsureInput.RegistrationContext == nil {
require.FailNow(t, "last ensure registration context = nil, want value")
}
if recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage != "en" {
require.Failf(t, "test failed", "preferred language = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage, "en")
if recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage != "fr-FR" {
require.Failf(t, "test failed", "preferred language = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage, "fr-FR")
}
if recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone != confirmEmailCodeTimeZone {
require.Failf(t, "test failed", "time zone = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone, confirmEmailCodeTimeZone)
@@ -703,6 +713,7 @@ func sentChallengeFixture(
ID: common.ChallengeID(challengeID),
Email: common.Email(email),
CodeHash: codeHash,
PreferredLanguage: "en",
Status: challenge.StatusSent,
DeliveryState: challenge.DeliverySent,
CreatedAt: createdAt,
@@ -20,6 +20,11 @@ type Input struct {
// Email is the user-supplied e-mail address that should receive the login
// code.
Email string
// AcceptLanguage stores the optional public Accept-Language header forwarded
// by gateway for auth-mail localization and create-only registration
// context.
AcceptLanguage string
}
// Result describes one public send-email-code response.
@@ -160,6 +165,7 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
if err != nil {
return Result{}, err
}
preferredLanguage := shared.ResolvePreferredLanguage(input.AcceptLanguage)
now := s.clock.Now().UTC()
abuseResult, err := s.abuseProtector.CheckAndReserve(ctx, ports.SendEmailCodeAbuseInput{
@@ -194,6 +200,7 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
ID: challengeID,
Email: email,
CodeHash: codeHash,
PreferredLanguage: preferredLanguage,
Status: pendingStatus,
DeliveryState: pendingDeliveryState,
CreatedAt: now,
@@ -241,7 +248,9 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
default:
deliveryResult, err := s.mailSender.SendLoginCode(ctx, ports.SendLoginCodeInput{
Email: email,
IdempotencyKey: challengeID.String(),
Code: code,
Locale: preferredLanguage,
})
if err != nil {
final.Status = challenge.StatusFailed
@@ -72,6 +72,9 @@ func TestExecuteSendsChallengeForExistingAndCreatableUsers(t *testing.T) {
if len(mailSender.RecordedInputs()) != 1 {
require.Failf(t, "test failed", "RecordedInputs() length = %d, want 1", len(mailSender.RecordedInputs()))
}
if mailSender.RecordedInputs()[0].Locale != "en" {
require.Failf(t, "test failed", "mail locale = %q, want %q", mailSender.RecordedInputs()[0].Locale, "en")
}
record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
if err != nil {
@@ -83,6 +86,9 @@ func TestExecuteSendsChallengeForExistingAndCreatableUsers(t *testing.T) {
if record.Attempts.Send != 1 {
require.Failf(t, "test failed", "Attempts.Send = %d, want 1", record.Attempts.Send)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
if string(record.CodeHash) == "654321" {
require.FailNow(t, "CodeHash stored cleartext code")
}
@@ -131,6 +137,9 @@ func TestExecuteSuppressesDeliveryForBlockedEmail(t *testing.T) {
if record.Status != challenge.StatusDeliverySuppressed || record.DeliveryState != challenge.DeliverySuppressed {
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
}
func TestExecuteHandlesMailSenderSuppressedOutcome(t *testing.T) {
@@ -166,6 +175,9 @@ func TestExecuteHandlesMailSenderSuppressedOutcome(t *testing.T) {
if record.Status != challenge.StatusDeliverySuppressed || record.DeliveryState != challenge.DeliverySuppressed {
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
}
func TestExecuteMarksChallengeFailedWhenMailSenderFails(t *testing.T) {
@@ -199,6 +211,9 @@ func TestExecuteMarksChallengeFailedWhenMailSenderFails(t *testing.T) {
if record.Status != challenge.StatusFailed || record.DeliveryState != challenge.DeliveryFailed {
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
}
func TestExecuteReturnsInvalidRequestForBadEmail(t *testing.T) {
@@ -308,3 +323,69 @@ func TestExecuteSetsChallengeExpirationFromInitialTTL(t *testing.T) {
require.Failf(t, "test failed", "ExpiresAt = %s, want %s", record.ExpiresAt, wantExpiresAt)
}
}
func TestExecuteResolvesPreferredLanguageFromAcceptLanguage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
acceptLanguage string
wantPreferredLang string
}{
{
name: "canonical valid tag wins",
acceptLanguage: "fr-FR, en;q=0.8",
wantPreferredLang: "fr-FR",
},
{
name: "wildcard falls back to english",
acceptLanguage: "*",
wantPreferredLang: "en",
},
{
name: "malformed header falls back to english",
acceptLanguage: "fr-FR, @@",
wantPreferredLang: "en",
},
{
name: "missing header falls back to english",
acceptLanguage: "",
wantPreferredLang: "en",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
challengeStore := &testkit.InMemoryChallengeStore{}
mailSender := &testkit.RecordingMailSender{}
service, err := New(
challengeStore,
&testkit.InMemoryUserDirectory{},
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
testkit.FixedCodeGenerator{Code: "654321"},
testkit.DeterministicCodeHasher{},
mailSender,
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), Input{
Email: "pilot@example.com",
AcceptLanguage: tt.acceptLanguage,
})
require.NoError(t, err)
record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
require.NoError(t, err)
require.Equal(t, tt.wantPreferredLang, record.PreferredLanguage)
attempts := mailSender.RecordedInputs()
require.Len(t, attempts, 1)
require.Equal(t, tt.wantPreferredLang, attempts[0].Locale)
})
}
}
@@ -93,6 +93,7 @@ func TestExecuteWithStubSender(t *testing.T) {
require.Len(t, attempts, tt.wantRecordedAttempt)
assert.Equal(t, common.Email("pilot@example.com"), attempts[0].Input.Email)
assert.Equal(t, "654321", attempts[0].Input.Code)
assert.Equal(t, "en", attempts[0].Input.Locale)
})
}
}
@@ -0,0 +1,27 @@
package shared
import "golang.org/x/text/language"
const defaultPreferredLanguage = "en"
// ResolvePreferredLanguage returns the first canonical BCP 47 language tag
// accepted from value, or the stable "en" fallback when the input is absent,
// malformed, or too unspecific for auth registration purposes.
func ResolvePreferredLanguage(value string) string {
tags, _, err := language.ParseAcceptLanguage(value)
if err != nil {
return defaultPreferredLanguage
}
for _, tag := range tags {
canonical := tag.String()
switch canonical {
case "", "und", "mul":
continue
default:
return canonical
}
}
return defaultPreferredLanguage
}
@@ -0,0 +1,51 @@
package shared
import "testing"
func TestResolvePreferredLanguage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
}{
{
name: "canonical valid tag",
value: "fr-FR, en;q=0.8",
want: "fr-FR",
},
{
name: "quality ordering",
value: "en-US;q=0.9, fr",
want: "fr",
},
{
name: "wildcard falls back",
value: "*",
want: "en",
},
{
name: "malformed falls back",
value: "fr-FR, @@",
want: "en",
},
{
name: "missing falls back",
value: "",
want: "en",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ResolvePreferredLanguage(tt.value); got != tt.want {
t.Fatalf("ResolvePreferredLanguage(%q) = %q, want %q", tt.value, got, tt.want)
}
})
}
}
@@ -72,6 +72,7 @@ func challengeFixture() challenge.Challenge {
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
+2 -1
View File
@@ -8,7 +8,8 @@ import (
)
// RecordingMailSender is a deterministic MailSender double that records every
// delivery request and returns preconfigured outcomes or errors.
// delivery request, including the auth challenge-derived idempotency key, and
// returns preconfigured outcomes or errors.
type RecordingMailSender struct {
mu sync.Mutex
@@ -98,7 +98,9 @@ func TestRecordingMailSender(t *testing.T) {
result, err := sender.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
})
if err != nil {
require.Failf(t, "test failed", "SendLoginCode() returned error: %v", err)
@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -35,6 +36,10 @@ func TestMailServiceRESTCompatibilitySendEmailCodeSent(t *testing.T) {
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body)
assert.Equal(t, 1, harness.mailServer.CallCount())
deliveries := harness.mailServer.RecordedDeliveries()
require.Len(t, deliveries, 1)
assert.Equal(t, "en", deliveries[0].Locale)
assert.Equal(t, "challenge-1", deliveries[0].IdempotencyKey)
}
func TestMailServiceRESTCompatibilitySendEmailCodeSuppressed(t *testing.T) {
@@ -99,6 +104,29 @@ func TestMailServiceRESTCompatibilityThrottledSendSkipsMailService(t *testing.T)
assert.Equal(t, 1, harness.mailServer.CallCount())
}
func TestMailServiceRESTCompatibilitySendEmailCodeForwardsLocalizedLocale(t *testing.T) {
t.Parallel()
harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{
MailStatusCode: http.StatusOK,
MailResponse: `{"outcome":"sent"}`,
})
response := gatewayCompatibilityPostJSONWithHeaders(
t,
harness.publicBaseURL+"/api/v1/public/auth/send-email-code",
`{"email":"pilot@example.com"}`,
map[string]string{"Accept-Language": "fr-FR, en;q=0.8"},
)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body)
deliveries := harness.mailServer.RecordedDeliveries()
require.Len(t, deliveries, 1)
assert.Equal(t, "fr-FR", deliveries[0].Locale)
assert.Equal(t, "challenge-1", deliveries[0].IdempotencyKey)
}
type mailServiceRESTCompatibilityOptions struct {
MailStatusCode int
MailResponse string
@@ -191,6 +219,14 @@ type mailServiceStubServer struct {
statusCode int
response string
callCount int
deliveries []mailServiceStubDelivery
}
type mailServiceStubDelivery struct {
Email string
Code string
Locale string
IdempotencyKey string
}
func newMailServiceStubServer(statusCode int, response string) *mailServiceStubServer {
@@ -206,10 +242,10 @@ func (s *mailServiceStubServer) Handler() http.Handler {
http.NotFound(writer, request)
return
}
s.mu.Lock()
s.callCount++
s.mu.Unlock()
if strings.TrimSpace(request.Header.Get("Idempotency-Key")) == "" {
http.Error(writer, "Idempotency-Key header must not be empty", http.StatusBadRequest)
return
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
@@ -217,6 +253,7 @@ func (s *mailServiceStubServer) Handler() http.Handler {
var body struct {
Email string `json:"email"`
Code string `json:"code"`
Locale string `json:"locale"`
}
if err := decoder.Decode(&body); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
@@ -231,6 +268,16 @@ func (s *mailServiceStubServer) Handler() http.Handler {
return
}
s.mu.Lock()
s.callCount++
s.deliveries = append(s.deliveries, mailServiceStubDelivery{
Email: body.Email,
Code: body.Code,
Locale: body.Locale,
IdempotencyKey: request.Header.Get("Idempotency-Key"),
})
s.mu.Unlock()
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(s.statusCode)
_, _ = io.WriteString(writer, s.response)
@@ -243,3 +290,12 @@ func (s *mailServiceStubServer) CallCount() int {
return s.callCount
}
func (s *mailServiceStubServer) RecordedDeliveries() []mailServiceStubDelivery {
s.mu.Lock()
defer s.mu.Unlock()
cloned := make([]mailServiceStubDelivery, len(s.deliveries))
copy(cloned, s.deliveries)
return cloned
}
+1
View File
@@ -735,6 +735,7 @@ func TestProductionHardeningExpiredChallengeReturnsExpiredDuringGraceAndNotFound
ID: common.ChallengeID("challenge-expired"),
Email: common.Email(gatewayCompatibilityEmail),
CodeHash: codeHash,
PreferredLanguage: "en",
Status: challenge.StatusSent,
DeliveryState: challenge.DeliverySent,
CreatedAt: env.now.Add(-2 * time.Minute),
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
@@ -57,7 +58,9 @@ func TestUserServiceRESTCompatibilityPublicSendUsesResolveByEmailOutcomes(t *tes
attempts := harness.mailSender.RecordedAttempts()
require.Len(t, attempts, 2)
assert.Equal(t, common.Email("existing@example.com"), attempts[0].Input.Email)
assert.Equal(t, "en", attempts[0].Input.Locale)
assert.Equal(t, common.Email("creatable@example.com"), attempts[1].Input.Email)
assert.Equal(t, "en", attempts[1].Input.Locale)
}
func TestUserServiceRESTCompatibilityPublicConfirmUsesEnsureOutcomes(t *testing.T) {
@@ -162,12 +165,34 @@ func TestUserServiceRESTCompatibilityInternalBlockUserUsesRESTClient(t *testing.
})
}
func TestUserServiceRESTCompatibilityAcceptLanguageDrivesMailLocaleAndRegistrationContext(t *testing.T) {
t.Parallel()
harness := newUserServiceRESTCompatibilityHarness(t)
require.NoError(t, harness.directory.QueueCreatedUserIDs(common.UserID("user-created")))
challengeID := harness.sendChallengeIDWithAcceptLanguage(t, "localized@example.com", "fr-FR, en;q=0.8", "fr-FR")
attempts := harness.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
assert.Equal(t, "fr-FR", attempts[0].Input.Locale)
response := gatewayCompatibilityPostJSONValue(
t,
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, response.Body)
}
type userServiceRESTCompatibilityHarness struct {
publicBaseURL string
internalBaseURL string
mailSender *mail.StubSender
sessionStore *testkit.InMemorySessionStore
directory *userservice.StubDirectory
preferredLanguageExpectations *preferredLanguageExpectationStore
}
func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompatibilityHarness {
@@ -176,8 +201,9 @@ func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompati
challengeStore := &testkit.InMemoryChallengeStore{}
sessionStore := &testkit.InMemorySessionStore{}
directory := &userservice.StubDirectory{}
preferredLanguageExpectations := newPreferredLanguageExpectationStore()
userServiceServer := httptest.NewServer(newUserServiceStubHandler(directory))
userServiceServer := httptest.NewServer(newUserServiceStubHandler(directory, preferredLanguageExpectations))
t.Cleanup(userServiceServer.Close)
userDirectory, err := userservice.NewRESTClient(userservice.Config{
@@ -266,13 +292,26 @@ func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompati
mailSender: mailSender,
sessionStore: sessionStore,
directory: directory,
preferredLanguageExpectations: preferredLanguageExpectations,
}
}
func (h userServiceRESTCompatibilityHarness) sendChallengeID(t *testing.T, email string) string {
t.Helper()
response := gatewayCompatibilityPostJSON(t, h.publicBaseURL+"/api/v1/public/auth/send-email-code", fmt.Sprintf(`{"email":"%s"}`, email))
return h.sendChallengeIDWithAcceptLanguage(t, email, "", "en")
}
func (h userServiceRESTCompatibilityHarness) sendChallengeIDWithAcceptLanguage(t *testing.T, email string, acceptLanguage string, expectedPreferredLanguage string) string {
t.Helper()
h.preferredLanguageExpectations.Set(email, expectedPreferredLanguage)
response := gatewayCompatibilityPostJSONWithHeaders(
t,
h.publicBaseURL+"/api/v1/public/auth/send-email-code",
fmt.Sprintf(`{"email":"%s"}`, email),
map[string]string{"Accept-Language": acceptLanguage},
)
assert.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -284,7 +323,7 @@ func (h userServiceRESTCompatibilityHarness) sendChallengeID(t *testing.T, email
return body.ChallengeID
}
func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handler {
func newUserServiceStubHandler(directory *userservice.StubDirectory, preferredLanguageExpectations *preferredLanguageExpectationStore) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/user-resolutions/by-email":
@@ -349,8 +388,13 @@ func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handle
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context must be present"))
return
}
if ensureInput.RegistrationContext.PreferredLanguage != "en" {
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.preferred_language must equal en during rollout"))
expectedPreferredLanguage := preferredLanguageExpectations.Expected(input.Email)
if ensureInput.RegistrationContext.PreferredLanguage != expectedPreferredLanguage {
writeUserServiceStubError(
writer,
http.StatusBadRequest,
fmt.Errorf("registration_context.preferred_language must equal %s", expectedPreferredLanguage),
)
return
}
if ensureInput.RegistrationContext.TimeZone != gatewayCompatibilityTimeZone {
@@ -434,6 +478,44 @@ func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handle
})
}
type preferredLanguageExpectationStore struct {
mu sync.Mutex
byEmail map[string]string
}
func newPreferredLanguageExpectationStore() *preferredLanguageExpectationStore {
return &preferredLanguageExpectationStore{
byEmail: make(map[string]string),
}
}
func (s *preferredLanguageExpectationStore) Set(email string, preferredLanguage string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.byEmail[email] = preferredLanguage
}
func (s *preferredLanguageExpectationStore) Expected(email string) string {
if s == nil {
return "en"
}
s.mu.Lock()
defer s.mu.Unlock()
preferredLanguage := s.byEmail[email]
if preferredLanguage == "" {
return "en"
}
return preferredLanguage
}
func decodeUserServiceStubRequest(writer http.ResponseWriter, request *http.Request, target any) bool {
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
+1 -1
View File
@@ -40,7 +40,7 @@ require (
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+1 -1
View File
@@ -74,7 +74,7 @@ golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+1 -1
View File
@@ -39,7 +39,7 @@ require (
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+1 -1
View File
@@ -74,7 +74,7 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+9 -4
View File
@@ -116,14 +116,19 @@ The public auth JSON contract uses a challenge-token flow:
`client_public_key`, and `time_zone`, then returns
`device_session_id`.
The JSON body for `send-email-code` remains unchanged, but gateway may also
consume the standard `Accept-Language` header on that route. Gateway resolves
the first supported BCP 47 language tag, falls back to `en` when needed, and
forwards that derived preferred-language candidate to
`Auth / Session Service` for localized auth mail and possible first-user
creation. The public JSON DTO itself remains unchanged.
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key for the device session being created.
`time_zone` is the client-selected IANA time zone name forwarded unchanged to
`Auth / Session Service`.
The current create-path source of truth for `preferred_language` is still the
temporary authsession-to-user rollout using `"en"`. Gateway-side language
derivation is a later rollout. The public `confirm-email-code` DTO itself
remains unchanged.
The current create-path source of truth for `preferred_language` is the
language candidate derived from public `Accept-Language`, with fallback to
`en`. The public `confirm-email-code` DTO itself remains unchanged.
These routes remain unauthenticated and delegate only through an injected
`AuthServiceClient`.
+11 -7
View File
@@ -1,10 +1,14 @@
# TODOs
## 1. Suggest User's Preferred Language when registering a new User
## 1. Improve Preferred-Language Fallback after the Current Accept-Language Rollout
Upon user's device/session registration flow, `preferred_language` value
must be obtained via existing [geoip](../pkg/geoip) package by returned
country.
The derived value must be emitted as a valid BCP 47 language tag because
`User Service` now validates that contract semantically on create.
When geoip fails to return country by IP, fallback is `en`.
The current auth-registration flow derives the preferred-language candidate
from the public `Accept-Language` header and falls back to `en` when no
supported tag is available.
A later improvement may use the existing [geoip](../pkg/geoip) package as an
additional fallback when `Accept-Language` is absent or unusable, but it must:
- preserve the current public JSON DTOs
- continue emitting a valid BCP 47 tag for `User Service`
- keep `en` as the final safe fallback
+2 -1
View File
@@ -23,6 +23,7 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
golang.org/x/text v0.36.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
@@ -56,6 +57,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -91,7 +93,6 @@ require (
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+34 -3
View File
@@ -17,9 +17,11 @@ github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdb
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -29,12 +31,15 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -53,9 +58,11 @@ github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/Nu
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -75,8 +82,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -101,12 +107,16 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -116,6 +126,7 @@ github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxza
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -152,20 +163,33 @@ go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzyb
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -177,22 +201,29 @@ go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -92,7 +92,9 @@ func (c *HTTPAuthServiceClient) Close() error {
// SendEmailCode delegates the public send-email-code route to the configured
// Auth / Session Service public HTTP API.
func (c *HTTPAuthServiceClient) SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error) {
payload, statusCode, err := c.doJSONRequest(ctx, authServiceSendEmailCodePath, input)
payload, statusCode, err := c.doJSONRequest(ctx, authServiceSendEmailCodePath, input, map[string]string{
"Accept-Language": resolvePreferredLanguage(input.PreferredLanguage),
})
if err != nil {
return SendEmailCodeResult{}, fmt.Errorf("send email code via auth service: %w", err)
}
@@ -123,7 +125,7 @@ func (c *HTTPAuthServiceClient) SendEmailCode(ctx context.Context, input SendEma
// ConfirmEmailCode delegates the public confirm-email-code route to the
// configured Auth / Session Service public HTTP API.
func (c *HTTPAuthServiceClient) ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
payload, statusCode, err := c.doJSONRequest(ctx, authServiceConfirmEmailCodePath, input)
payload, statusCode, err := c.doJSONRequest(ctx, authServiceConfirmEmailCodePath, input, nil)
if err != nil {
return ConfirmEmailCodeResult{}, fmt.Errorf("confirm email code via auth service: %w", err)
}
@@ -151,7 +153,7 @@ func (c *HTTPAuthServiceClient) ConfirmEmailCode(ctx context.Context, input Conf
}
}
func (c *HTTPAuthServiceClient) doJSONRequest(ctx context.Context, path string, requestBody any) ([]byte, int, error) {
func (c *HTTPAuthServiceClient) doJSONRequest(ctx context.Context, path string, requestBody any, headers map[string]string) ([]byte, int, error) {
if c == nil || c.httpClient == nil {
return nil, 0, errors.New("nil client")
}
@@ -172,6 +174,12 @@ func (c *HTTPAuthServiceClient) doJSONRequest(ctx context.Context, path string,
return nil, 0, fmt.Errorf("build request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if strings.TrimSpace(value) == "" {
continue
}
request.Header.Set(key, value)
}
response, err := c.httpClient.Do(request)
if err != nil {
@@ -66,12 +66,14 @@ func TestHTTPAuthServiceClientSendEmailCodeSuccess(t *testing.T) {
t.Parallel()
var requestContentType string
var requestAcceptLanguage string
var requestBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, authServiceSendEmailCodePath, r.URL.Path)
requestContentType = r.Header.Get("Content-Type")
requestAcceptLanguage = r.Header.Get("Accept-Language")
payload, err := io.ReadAll(r.Body)
require.NoError(t, err)
requestBody = string(payload)
@@ -86,13 +88,34 @@ func TestHTTPAuthServiceClientSendEmailCodeSuccess(t *testing.T) {
result, err := client.SendEmailCode(context.Background(), SendEmailCodeInput{
Email: "pilot@example.com",
PreferredLanguage: "fr-FR",
})
require.NoError(t, err)
assert.Equal(t, SendEmailCodeResult{ChallengeID: "challenge-123"}, result)
assert.Equal(t, "application/json", requestContentType)
assert.Equal(t, "fr-FR", requestAcceptLanguage)
assert.JSONEq(t, `{"email":"pilot@example.com"}`, requestBody)
}
func TestHTTPAuthServiceClientSendEmailCodeDefaultsAcceptLanguageToEnglish(t *testing.T) {
t.Parallel()
var requestAcceptLanguage string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestAcceptLanguage = r.Header.Get("Accept-Language")
w.Header().Set("Content-Type", "application/json")
_, err := io.WriteString(w, `{"challenge_id":"challenge-123"}`)
require.NoError(t, err)
}))
defer server.Close()
client := newTestHTTPAuthServiceClient(t, server)
_, err := client.SendEmailCode(context.Background(), SendEmailCodeInput{Email: "pilot@example.com"})
require.NoError(t, err)
assert.Equal(t, "en", requestAcceptLanguage)
}
func TestHTTPAuthServiceClientConfirmEmailCodeSuccess(t *testing.T) {
t.Parallel()
@@ -0,0 +1,24 @@
package restapi
import "golang.org/x/text/language"
const defaultPreferredLanguage = "en"
func resolvePreferredLanguage(value string) string {
tags, _, err := language.ParseAcceptLanguage(value)
if err != nil {
return defaultPreferredLanguage
}
for _, tag := range tags {
canonical := tag.String()
switch canonical {
case "", "und", "mul":
continue
default:
return canonical
}
}
return defaultPreferredLanguage
}
@@ -0,0 +1,51 @@
package restapi
import "testing"
func TestResolvePreferredLanguage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
}{
{
name: "canonical valid tag",
value: "fr-FR, en;q=0.8",
want: "fr-FR",
},
{
name: "quality ordering",
value: "en-US;q=0.9, fr",
want: "fr",
},
{
name: "wildcard falls back",
value: "*",
want: "en",
},
{
name: "malformed falls back",
value: "fr-FR, @@",
want: "en",
},
{
name: "missing falls back",
value: "",
want: "en",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := resolvePreferredLanguage(tt.value); got != tt.want {
t.Fatalf("resolvePreferredLanguage(%q) = %q, want %q", tt.value, got, tt.want)
}
})
}
}
+6
View File
@@ -55,6 +55,11 @@ type SendEmailCodeInput struct {
// Email is the single client e-mail address that should receive the login
// code challenge.
Email string `json:"email"`
// PreferredLanguage stores the canonical BCP 47 language tag derived from
// the public Accept-Language header for upstream auth-mail localization and
// create-only user registration context.
PreferredLanguage string `json:"-"`
}
// SendEmailCodeResult describes the public REST and adapter payload returned
@@ -204,6 +209,7 @@ func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) g
abortInvalidRequest(c, err.Error())
return
}
input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language"))
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
+5 -1
View File
@@ -34,6 +34,7 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
strings.NewReader(`{"email":" pilot@example.com "}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "fr-FR, en;q=0.8")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
@@ -43,7 +44,10 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
assert.Equal(t, `{"challenge_id":"challenge-123"}`, recorder.Body.String())
assert.Equal(t, 1, authService.sendEmailCodeCalls)
assert.Equal(t, 0, authService.confirmEmailCodeCalls)
assert.Equal(t, SendEmailCodeInput{Email: "pilot@example.com"}, authService.sendEmailCodeInput)
assert.Equal(t, SendEmailCodeInput{
Email: "pilot@example.com",
PreferredLanguage: "fr-FR",
}, authService.sendEmailCodeInput)
assert.True(t, authService.sendEmailCodeRouteClassOK)
assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass)
}
+19
View File
@@ -134,6 +134,11 @@ paths:
that must later be confirmed through
`POST /api/v1/public/auth/confirm-email-code`.
The JSON body stays unchanged. Callers may additionally supply the
standard `Accept-Language` header so the gateway can derive the
auth-mail locale and first-login preferred-language candidate. Missing
or unsupported values fall back to `en`.
This route is unauthenticated and classified as `public_auth`.
Public REST anti-abuse applies a per-IP bucket derived from
`RemoteAddr` and an additional normalized identity bucket derived from
@@ -146,6 +151,8 @@ paths:
gateway preserves that projected `4xx/5xx` status and serialized error
envelope after normalizing blank or invalid fields.
security: []
parameters:
- $ref: "#/components/parameters/AcceptLanguage"
x-public-route-classification-note: |
This route is always classified as `public_auth`.
requestBody:
@@ -250,6 +257,18 @@ paths:
default:
$ref: "#/components/responses/ProjectedAuthServiceError"
components:
parameters:
AcceptLanguage:
name: Accept-Language
in: header
required: false
description: |
Optional RFC 9110 `Accept-Language` header used by gateway to derive
the auth-mail locale and first-login preferred-language candidate.
The first supported BCP 47 tag wins; unsupported or missing values
fall back to `en`.
schema:
type: string
schemas:
HealthzResponse:
type: object
+4 -2
View File
@@ -299,9 +299,11 @@ Tasks:
- Define the event payload for `country_review_recommended=true`.
- Implement event publication on transition to `true`.
- Implement configuration-driven email notification through `Mail Service`.
- Implement configuration-driven notification handoff through
`Notification Service`.
- Add notification deduplication or transition-only logic to prevent spam.
- Add failure metrics for both event publication and mail send.
- Add failure metrics for both event publication and downstream notification
handoff.
Important constraints:
+14 -8
View File
@@ -32,7 +32,7 @@ The service is embedded into an already existing trusted microservice environmen
- `Edge Service`
- `Auth / Session Service`
- `User Service`
- `Mail Service`
- `Notification Service`
- Internal event bus
`Edge Service` is the producer of authenticated connection observations.
@@ -41,7 +41,9 @@ The service is embedded into an already existing trusted microservice environmen
`Auth / Session Service` remains the owner of session lifecycle and session blocking.
`Mail Service` is used only for optional administrative notifications.
`Notification Service` is used for optional administrative notifications,
which may later result in e-mail delivery through `Mail Service`.
Geo Profile Service does not call `Mail Service` directly.
The event bus is used only as an auxiliary notification channel and not as the authoritative source of business state.
@@ -146,7 +148,8 @@ flowchart LR
Edge -. async flatbuffers ingest .-> Geo[Geo Profile Service]
Geo --> User[User Service]
Geo --> Mail[Mail Service]
Geo --> Notify[Notification Service]
Notify --> Mail[Mail Service]
Geo --> Bus[Event Bus]
Geo --> Auth
@@ -467,7 +470,7 @@ Meaning:
Producer:
- Geo Profile Service via `Mail Service`
- Geo Profile Service via `Notification Service`
Consumers:
@@ -794,16 +797,19 @@ Contract assumptions:
This keeps the hot path simple and avoids synchronous enforcement coupling.
## Integration with Mail Service
## Integration with Notification Service
Mail notifications are optional and configuration-driven.
Administrative notifications are optional and configuration-driven.
Mail is sent only when:
Notification routing is triggered only when:
- `country_review_recommended` transitions to `true`
- Email notifications are enabled
Mail is auxiliary and must not be required for business correctness.
`Notification Service` may then fan out e-mail delivery through
`Mail Service`.
Geo Profile Service itself never sends mail directly.
That path is auxiliary and must not be required for business correctness.
## Event Bus Integration
+1
View File
@@ -6,6 +6,7 @@ use (
./game
./gateway
./integration
./mail
./pkg/calc
./pkg/connector
./pkg/error
+9 -3
View File
@@ -11,6 +11,7 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
@@ -18,7 +19,7 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -36,6 +37,9 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucor/goinfo v0.9.0/go.mod h1:L6m6tN5Rlova5Z83h1ZaKsMP1iiaoZ9vGTNzu5QKOD4=
github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -49,7 +53,9 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -84,6 +90,7 @@ golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -136,7 +143,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -159,11 +165,11 @@ golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
+34 -4
View File
@@ -8,6 +8,12 @@ Each suite must raise real service processes, speak only over public HTTP/gRPC/R
```text
integration/
├── README.md
├── authsessionmail/
│ ├── authsession_mail_test.go
│ └── harness_test.go
├── gatewayauthsessionmail/
│ ├── gateway_authsession_mail_test.go
│ └── harness_test.go
├── authsessionuser/
│ ├── authsession_user_test.go
│ └── harness_test.go
@@ -33,6 +39,8 @@ integration/
├── keys.go
├── mail_stub.go
├── process.go
├── redis_container.go
├── smtp_capture.go
└── user_stub.go
```
@@ -48,11 +56,20 @@ integration/
- `gatewayauthsession` verifies the integration boundary between real `Edge Gateway` and real `Auth / Session Service`.
- `authsessionuser` verifies the integration boundary between real `Auth / Session Service` and real `User Service`.
- `authsessionmail` verifies the integration boundary between real `Auth / Session Service` and real `Mail Service`.
- `gatewayauthsessionmail` verifies the public auth flow across real `Edge Gateway`, real `Auth / Session Service`, and real `Mail Service`.
- `gatewayuser` verifies the direct authenticated self-service boundary between real `Edge Gateway` and real `User Service`.
- `gatewayauthsessionuser` verifies the full public-auth plus authenticated-account chain across real `Edge Gateway`, real `Auth / Session Service`, and real `User Service`.
The current fast suites use one isolated `miniredis` instance plus either
The current fast suites still use one isolated `miniredis` instance plus either
real downstream processes or external stateful HTTP stubs where appropriate.
`authsessionmail` and `gatewayauthsessionmail` are the deliberate exceptions:
they use one real Redis container through `testcontainers-go`, because those
boundaries must exercise the real Redis-backed `Mail Service` runtime.
`authsessionmail` additionally contains one targeted SMTP-capture scenario for
the real `smtp` provider path, while `gatewayauthsessionmail` keeps `Mail
Service` in `stub` mode and extracts the confirmation code through the trusted
operator delivery surface.
## Running
@@ -62,6 +79,8 @@ Run from the module directory:
cd integration
go test ./gatewayauthsession/...
go test ./authsessionuser/...
go test ./authsessionmail/...
go test ./gatewayauthsessionmail/...
go test ./gatewayuser/...
go test ./gatewayauthsessionuser/...
```
@@ -71,6 +90,8 @@ Useful regression commands after boundary changes:
```bash
go test ./gatewayauthsession/...
go test ./authsessionuser/...
go test ./authsessionmail/...
go test ./gatewayauthsessionmail/...
go test ./gatewayuser/...
go test ./gatewayauthsessionuser/...
cd ../gateway && go test ./...
@@ -88,8 +109,17 @@ Do not use `go test ./...` from the repository root. The repository is organized
4. Add new helpers to `internal/contracts/<contract>/` only when they describe a reusable public wire contract.
5. Prefer fast deterministic infrastructure by default: in-memory test doubles, `httptest` stubs, and `miniredis`.
## Future Real Redis Smoke Suites
## Real Redis Suites
Fast suites stay on `miniredis` by default.
When a boundary needs one real Redis smoke suite later, keep it in the same boundary package and gate it explicitly with environment-driven configuration such as `INTEGRATION_REAL_REDIS_ADDR`.
That smoke suite should complement, not replace, the deterministic `miniredis` coverage.
When one boundary explicitly needs real Redis semantics, prefer a package-local
container setup through `testcontainers-go` plus reusable helpers in
`internal/harness`, as done by `authsessionmail` and
`gatewayauthsessionmail`.
Current rule of thumb:
- use `miniredis` when the boundary does not depend on Redis persistence or
scheduling behavior
- use `testcontainers-go` only when the real Redis process materially changes
the behavior being verified
@@ -0,0 +1,110 @@
package authsessionmail_test
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAuthsessionMailBlackBoxSendEmailCodeCreatesSuppressedDelivery(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{})
email := "pilot@example.com"
response := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
require.NotEmpty(t, response.ChallengeID)
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"status": []string{"suppressed"},
"recipient": []string{email},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "suppressed", list.Items[0].Status)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{email}, list.Items[0].To)
detail := h.getDelivery(t, list.Items[0].DeliveryID)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "suppressed", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.False(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{email}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
attempts := h.getDeliveryAttempts(t, detail.DeliveryID)
require.Empty(t, attempts.Items)
}
func TestAuthsessionMailBlackBoxSendEmailCodeReturnsServiceUnavailableWhenMailServiceStops(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{})
h.stopMail(t)
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+authSendEmailCodePath,
map[string]string{"email": "pilot@example.com"},
nil,
)
require.Equal(t, 503, response.StatusCode)
require.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, response.Body)
}
func TestAuthsessionMailBlackBoxSMTPDeliveryReachesSentStateAndSMTPPayload(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{mailSMTPMode: "smtp"})
email := "pilot@example.com"
response := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
require.NotEmpty(t, response.ChallengeID)
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"recipient": []string{email},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{email}, list.Items[0].To)
var detail mailDeliveryDetailResponse
require.Eventually(t, func() bool {
detail = h.getDelivery(t, list.Items[0].DeliveryID)
return detail.Status == "sent"
}, 10*time.Second, 50*time.Millisecond)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "sent", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.True(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{email}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
code, ok := detail.TemplateVariables["code"].(string)
require.True(t, ok)
require.Len(t, code, 6)
var attempts mailDeliveryAttemptsResponse
require.Eventually(t, func() bool {
attempts = h.getDeliveryAttempts(t, detail.DeliveryID)
return len(attempts.Items) == 1 && attempts.Items[0].Status == "provider_accepted"
}, 10*time.Second, 50*time.Millisecond)
require.Len(t, attempts.Items, 1)
require.Equal(t, "provider_accepted", attempts.Items[0].Status)
require.NotNil(t, h.smtp)
var payload string
require.Eventually(t, func() bool {
payload = h.smtp.LatestPayload()
return payload != ""
}, 10*time.Second, 50*time.Millisecond)
require.Contains(t, payload, "Subject:")
require.Contains(t, payload, "Your login code is "+code+".")
}
+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), "..", ".."))
}
@@ -64,6 +64,31 @@ func TestAuthsessionUserBlackBoxConfirmForExistingUserKeepsCreateOnlySettings(t
require.Equal(t, "Europe/Paris", account.User.TimeZone)
}
func TestAuthsessionUserBlackBoxAcceptLanguageSetsLocalizedPreferredLanguage(t *testing.T) {
t.Parallel()
h := newAuthsessionUserHarness(t)
email := "localized@example.com"
challengeID := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
deliveries := h.mailStub.RecordedDeliveries()
require.NotEmpty(t, deliveries)
require.Equal(t, "fr-FR", deliveries[len(deliveries)-1].Locale)
code := lastMailCodeFor(t, h.mailStub, email)
response := h.confirmCode(t, challengeID, code)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, response, http.StatusOK, &confirmBody)
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
lookupResponse, account := lookupUserByEmail(t, h.userServiceURL, email)
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, formatStatusError(lookupResponse))
require.Equal(t, "fr-FR", account.User.PreferredLanguage)
require.Equal(t, testTimeZone, account.User.TimeZone)
}
func TestAuthsessionUserBlackBoxBlockedEmailSendIsSuccessShapedAndConfirmIsRejectedWithoutCreatingUser(t *testing.T) {
t.Parallel()
+24 -3
View File
@@ -82,9 +82,18 @@ func newAuthsessionUserHarness(t *testing.T) *authsessionUserHarness {
func (h *authsessionUserHarness) sendChallenge(t *testing.T, email string) string {
t.Helper()
response := postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
return h.sendChallengeWithAcceptLanguage(t, email, "")
}
func (h *authsessionUserHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+"/api/v1/public/auth/send-email-code",
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -116,12 +125,24 @@ type httpResponse struct {
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
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)
}
client := &http.Client{
Timeout: 250 * time.Millisecond,
@@ -77,6 +77,26 @@ func TestGatewayAuthSessionConfirmCreatesProjectionAndAllowsSubscribeEvents(t *t
assertBootstrapEvent(t, event, h.responseSignerPublicKey, "request-bootstrap")
}
func TestGatewayAuthSessionAcceptLanguageIsForwardedToMailAndUser(t *testing.T) {
h := newGatewayAuthSessionHarness(t, gatewayAuthSessionOptions{})
clientPrivateKey := newClientPrivateKey("localized")
challengeID, code := h.sendChallengeWithAcceptLanguage(t, testEmail, "fr-FR, en;q=0.8")
deliveries := h.mailStub.RecordedDeliveries()
require.NotEmpty(t, deliveries)
require.Equal(t, "fr-FR", deliveries[len(deliveries)-1].Locale)
response := h.confirmCode(t, challengeID, code, clientPrivateKey)
require.Equal(t, http.StatusOK, response.StatusCode)
ensureCalls := h.userStub.EnsureCalls()
require.Len(t, ensureCalls, 1)
require.Equal(t, testEmail, ensureCalls[0].Email)
require.Equal(t, "fr-FR", ensureCalls[0].PreferredLanguage)
require.Equal(t, testTimeZone, ensureCalls[0].TimeZone)
}
func TestGatewayAuthSessionRepeatedConfirmReturnsSameSessionID(t *testing.T) {
h := newGatewayAuthSessionHarness(t, gatewayAuthSessionOptions{})
+24 -3
View File
@@ -196,9 +196,18 @@ func (h *gatewayAuthSessionHarness) readGatewaySessionRecord(t *testing.T, devic
func (h *gatewayAuthSessionHarness) sendChallenge(t *testing.T, email string) (string, string) {
t.Helper()
response := postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
return h.sendChallengeWithAcceptLanguage(t, email, "")
}
func (h *gatewayAuthSessionHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) (string, string) {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.gatewayPublicURL+"/api/v1/public/auth/send-email-code",
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -284,12 +293,24 @@ type gatewaySessionRecord struct {
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
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)
}
client := &http.Client{Timeout: 5 * time.Second}
@@ -0,0 +1,87 @@
package gatewayauthsessionmail_test
import (
"context"
"crypto/ed25519"
"net/http"
"net/url"
"testing"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/require"
)
func TestGatewayAuthsessionMailSendAndConfirmWithRealMailService(t *testing.T) {
h := newGatewayAuthsessionMailHarness(t)
clientPrivateKey := newClientPrivateKey("real-mail")
challengeID := h.sendChallengeWithAcceptLanguage(t, testEmail, "fr-FR, en;q=0.8")
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"status": []string{"suppressed"},
"recipient": []string{testEmail},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "suppressed", list.Items[0].Status)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{testEmail}, list.Items[0].To)
detail := h.getDelivery(t, list.Items[0].DeliveryID)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "suppressed", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.False(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{testEmail}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
code := templateVariableString(t, detail.TemplateVariables, "code")
confirm := h.confirmCode(t, challengeID, code, clientPrivateKey)
require.Equal(t, http.StatusOK, confirm.StatusCode, confirm.Body)
var confirmBody confirmEmailCodeResponse
require.NoError(t, decodeStrictJSONPayload([]byte(confirm.Body), &confirmBody))
require.NotEmpty(t, confirmBody.DeviceSessionID)
record := h.waitForGatewaySession(t, confirmBody.DeviceSessionID)
require.Equal(t, gatewaySessionRecord{
DeviceSessionID: confirmBody.DeviceSessionID,
UserID: "user-1",
ClientPublicKey: encodePublicKey(clientPrivateKey.Public().(ed25519.PublicKey)),
Status: "active",
}, record)
ensureCalls := h.userStub.EnsureCalls()
require.Len(t, ensureCalls, 1)
require.Equal(t, testEmail, ensureCalls[0].Email)
require.Equal(t, "fr-FR", ensureCalls[0].PreferredLanguage)
require.Equal(t, testTimeZone, ensureCalls[0].TimeZone)
conn := h.dialGateway(t)
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newSubscribeEventsRequest(confirmBody.DeviceSessionID, "request-bootstrap", clientPrivateKey))
require.NoError(t, err)
event, err := stream.Recv()
require.NoError(t, err)
assertBootstrapEvent(t, event, h.responseSignerPublicKey, "request-bootstrap")
}
func TestGatewayAuthsessionMailUnavailablePassesThroughGatewaySurface(t *testing.T) {
h := newGatewayAuthsessionMailHarness(t)
h.stopMail(t)
response := postJSONValue(t, h.gatewayPublicURL+gatewaySendEmailCodePath, map[string]string{
"email": testEmail,
})
require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
require.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, response.Body)
}
@@ -0,0 +1,546 @@
package gatewayauthsessionmail_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"runtime"
"testing"
"time"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1"
"galaxy/integration/internal/harness"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
gatewaySendEmailCodePath = "/api/v1/public/auth/send-email-code"
gatewayConfirmEmailCodePath = "/api/v1/public/auth/confirm-email-code"
gatewayMailDeliveriesPath = "/api/v1/internal/deliveries"
testEmail = "pilot@example.com"
testTimeZone = "Europe/Kaliningrad"
)
type gatewayAuthsessionMailHarness struct {
redis *redis.Client
userStub *harness.UserStub
authsessionPublicURL string
authsessionInternalURL string
gatewayPublicURL string
gatewayGRPCAddr string
mailInternalURL string
responseSignerPublicKey ed25519.PublicKey
gatewayProcess *harness.Process
authsessionProcess *harness.Process
mailProcess *harness.Process
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type sendEmailCodeResponse struct {
ChallengeID string `json:"challenge_id"`
}
type confirmEmailCodeResponse struct {
DeviceSessionID string `json:"device_session_id"`
}
type gatewaySessionRecord struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status string `json:"status"`
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
}
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"`
}
func newGatewayAuthsessionMailHarness(t *testing.T) *gatewayAuthsessionMailHarness {
t.Helper()
redisRuntime := harness.StartRedisContainer(t)
redisClient := redis.NewClient(&redis.Options{
Addr: redisRuntime.Addr,
Protocol: 2,
DisableIdentity: true,
})
t.Cleanup(func() {
require.NoError(t, redisClient.Close())
})
userStub := harness.NewUserStub(t)
responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name())
mailInternalAddr := harness.FreeTCPAddress(t)
authsessionPublicAddr := harness.FreeTCPAddress(t)
authsessionInternalAddr := harness.FreeTCPAddress(t)
gatewayPublicAddr := harness.FreeTCPAddress(t)
gatewayGRPCAddr := harness.FreeTCPAddress(t)
mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail")
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway")
mailProcess := harness.StartProcess(t, "mail", mailBinary, map[string]string{
"MAIL_LOG_LEVEL": "info",
"MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr,
"MAIL_REDIS_ADDR": redisRuntime.Addr,
"MAIL_TEMPLATE_DIR": moduleTemplateDir(t),
"MAIL_SMTP_MODE": "stub",
"MAIL_STREAM_BLOCK_TIMEOUT": "100ms",
"MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(),
"MAIL_SHUTDOWN_TIMEOUT": "2s",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
waitForMailReady(t, mailProcess, "http://"+mailInternalAddr)
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, map[string]string{
"AUTHSESSION_LOG_LEVEL": "info",
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_REDIS_ADDR": redisRuntime.Addr,
"AUTHSESSION_USER_SERVICE_MODE": "rest",
"AUTHSESSION_USER_SERVICE_BASE_URL": userStub.BaseURL(),
"AUTHSESSION_USER_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
"AUTHSESSION_MAIL_SERVICE_BASE_URL": "http://" + mailInternalAddr,
"AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_REDIS_GATEWAY_SESSION_CACHE_KEY_PREFIX": "gateway:session:",
"AUTHSESSION_REDIS_GATEWAY_SESSION_EVENTS_STREAM": "gateway:session_events",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, map[string]string{
"GATEWAY_LOG_LEVEL": "info",
"GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr,
"GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr,
"GATEWAY_SESSION_CACHE_REDIS_ADDR": redisRuntime.Addr,
"GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:",
"GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events",
"GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events",
"GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:",
"GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath),
"GATEWAY_AUTH_SERVICE_BASE_URL": "http://" + authsessionPublicAddr,
"GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT": (500 * time.Millisecond).String(),
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK)
harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr)
return &gatewayAuthsessionMailHarness{
redis: redisClient,
userStub: userStub,
authsessionPublicURL: "http://" + authsessionPublicAddr,
authsessionInternalURL: "http://" + authsessionInternalAddr,
gatewayPublicURL: "http://" + gatewayPublicAddr,
gatewayGRPCAddr: gatewayGRPCAddr,
mailInternalURL: "http://" + mailInternalAddr,
responseSignerPublicKey: responseSignerPublicKey,
gatewayProcess: gatewayProcess,
authsessionProcess: authsessionProcess,
mailProcess: mailProcess,
}
}
func (h *gatewayAuthsessionMailHarness) stopMail(t *testing.T) {
t.Helper()
h.mailProcess.Stop(t)
}
func (h *gatewayAuthsessionMailHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.gatewayPublicURL+gatewaySendEmailCodePath,
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.ChallengeID
}
func (h *gatewayAuthsessionMailHarness) confirmCode(t *testing.T, challengeID string, code string, clientPrivateKey ed25519.PrivateKey) httpResponse {
t.Helper()
return postJSONValue(t, h.gatewayPublicURL+gatewayConfirmEmailCodePath, map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": encodePublicKey(clientPrivateKey.Public().(ed25519.PublicKey)),
"time_zone": testTimeZone,
})
}
func (h *gatewayAuthsessionMailHarness) 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 *gatewayAuthsessionMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
target := h.mailInternalURL + gatewayMailDeliveriesPath
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 *gatewayAuthsessionMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+gatewayMailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK)
}
func (h *gatewayAuthsessionMailHarness) waitForGatewaySession(t *testing.T, deviceSessionID string) gatewaySessionRecord {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
payload, err := h.redis.Get(context.Background(), "gateway:session:"+deviceSessionID).Bytes()
if err == nil {
var record gatewaySessionRecord
require.NoError(t, decodeStrictJSONPayload(payload, &record))
return record
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("gateway session projection for %s was not published in time", deviceSessionID)
return gatewaySessionRecord{}
}
func (h *gatewayAuthsessionMailHarness) dialGateway(t *testing.T) *grpc.ClientConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
h.gatewayGRPCAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, conn.Close())
})
return conn
}
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
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: 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(),
}
}
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 templateVariableString(t *testing.T, variables map[string]any, field string) string {
t.Helper()
value, ok := variables[field]
require.True(t, ok, "template variable %q is missing", field)
text, ok := value.(string)
require.True(t, ok, "template variable %q must be a string", field)
require.NotEmpty(t, text)
return text
}
func newClientPrivateKey(label string) ed25519.PrivateKey {
seed := sha256.Sum256([]byte("galaxy-integration-gateway-authsessionmail-client-" + label))
return ed25519.NewKeyFromSeed(seed[:])
}
func encodePublicKey(publicKey ed25519.PublicKey) string {
return base64.StdEncoding.EncodeToString(publicKey)
}
func newSubscribeEventsRequest(deviceSessionID string, requestID string, clientPrivateKey ed25519.PrivateKey) *gatewayv1.SubscribeEventsRequest {
payloadHash := contractsgatewayv1.ComputePayloadHash(nil)
request := &gatewayv1.SubscribeEventsRequest{
ProtocolVersion: contractsgatewayv1.ProtocolVersionV1,
DeviceSessionId: deviceSessionID,
MessageType: contractsgatewayv1.SubscribeMessageType,
TimestampMs: time.Now().UnixMilli(),
RequestId: requestID,
PayloadHash: payloadHash,
TraceId: "trace-" + requestID,
}
request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{
ProtocolVersion: request.GetProtocolVersion(),
DeviceSessionID: request.GetDeviceSessionId(),
MessageType: request.GetMessageType(),
TimestampMS: request.GetTimestampMs(),
RequestID: request.GetRequestId(),
PayloadHash: request.GetPayloadHash(),
})
return request
}
func assertBootstrapEvent(t *testing.T, event *gatewayv1.GatewayEvent, responseSignerPublicKey ed25519.PublicKey, wantRequestID string) {
t.Helper()
require.Equal(t, contractsgatewayv1.ServerTimeEventType, event.GetEventType())
require.Equal(t, wantRequestID, event.GetEventId())
require.Equal(t, wantRequestID, event.GetRequestId())
require.NoError(t, contractsgatewayv1.VerifyPayloadHash(event.GetPayloadBytes(), event.GetPayloadHash()))
require.NoError(t, contractsgatewayv1.VerifyEventSignature(responseSignerPublicKey, event.GetSignature(), contractsgatewayv1.EventSigningFields{
EventType: event.GetEventType(),
EventID: event.GetEventId(),
TimestampMS: event.GetTimestampMs(),
RequestID: event.GetRequestId(),
TraceID: event.GetTraceId(),
PayloadHash: event.GetPayloadHash(),
}))
}
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+gatewayMailDeliveriesPath, 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+gatewaySendEmailCodePath, 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), "..", ".."))
}
@@ -61,6 +61,30 @@ func TestGatewayAuthsessionUserExistingAccountKeepsCreateOnlySettings(t *testing
require.Equal(t, "Europe/Paris", accountResponse.Account.TimeZone)
}
func TestGatewayAuthsessionUserAcceptLanguageSetsLocalizedPreferredLanguage(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
const email = "localized@example.com"
challengeID := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
deliveries := h.mailStub.RecordedDeliveries()
require.NotEmpty(t, deliveries)
require.Equal(t, "fr-FR", deliveries[len(deliveries)-1].Locale)
code := lastMailCodeFor(t, h.mailStub, email)
clientPrivateKey := newClientPrivateKey("localized-account")
confirmResponse := h.confirmCode(t, challengeID, code, clientPrivateKey)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, confirmResponse, http.StatusOK, &confirmBody)
accountResponse := h.executeGetMyAccount(t, confirmBody.DeviceSessionID, "request-localized-account", clientPrivateKey)
require.Equal(t, "fr-FR", accountResponse.Account.PreferredLanguage)
require.Equal(t, gatewayAuthsessionUserTestTimeZone, accountResponse.Account.TimeZone)
}
func TestGatewayAuthsessionUserBlockedEmailAndUserBehavior(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
@@ -148,9 +148,18 @@ func newGatewayAuthsessionUserHarness(t *testing.T) *gatewayAuthsessionUserHarne
func (h *gatewayAuthsessionUserHarness) sendChallenge(t *testing.T, email string) string {
t.Helper()
response := postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
return h.sendChallengeWithAcceptLanguage(t, email, "")
}
func (h *gatewayAuthsessionUserHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.gatewayPublicURL+"/api/v1/public/auth/send-email-code",
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -299,12 +308,24 @@ type userLookupResponse struct {
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
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)
}
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Do(request)
+47 -5
View File
@@ -6,26 +6,68 @@ require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
google.golang.org/grpc v1.80.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+114 -1
View File
@@ -1,57 +1,170 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
@@ -22,6 +22,9 @@ type LoginCodeDelivery struct {
// Code stores the cleartext login code requested by authsession.
Code string
// Locale stores the canonical BCP 47 language tag selected by authsession.
Locale string
}
// MailBehavior overrides one external mail-stub response.
@@ -102,6 +105,7 @@ func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
var payload struct {
Email string `json:"email"`
Code string `json:"code"`
Locale string `json:"locale"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
@@ -112,6 +116,7 @@ func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
s.deliveries = append(s.deliveries, LoginCodeDelivery{
Email: payload.Email,
Code: payload.Code,
Locale: payload.Locale,
})
behavior := s.behavior
s.mu.Unlock()
@@ -0,0 +1,47 @@
package harness
import (
"context"
"testing"
testcontainers "github.com/testcontainers/testcontainers-go"
rediscontainer "github.com/testcontainers/testcontainers-go/modules/redis"
)
const defaultRedisContainerImage = "redis:7"
// RedisRuntime stores one started real Redis container together with the
// externally reachable endpoint used by black-box suites.
type RedisRuntime struct {
Container *rediscontainer.RedisContainer
Addr string
}
// StartRedisContainer starts one isolated real Redis container and registers
// automatic cleanup for the suite.
func StartRedisContainer(t testing.TB) *RedisRuntime {
t.Helper()
ctx := context.Background()
container, err := rediscontainer.Run(ctx, defaultRedisContainerImage)
if err != nil {
t.Fatalf("start redis container: %v", err)
}
t.Cleanup(func() {
if err := testcontainers.TerminateContainer(container); err != nil {
t.Errorf("terminate redis container: %v", err)
}
})
addr, err := container.Endpoint(ctx, "")
if err != nil {
t.Fatalf("resolve redis container endpoint: %v", err)
}
return &RedisRuntime{
Container: container,
Addr: addr,
}
}
@@ -0,0 +1,377 @@
package harness
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// SMTPCaptureConfig configures one local SMTP capture server.
type SMTPCaptureConfig struct {
// SupportsSTARTTLS controls whether the server advertises and accepts the
// STARTTLS upgrade command.
SupportsSTARTTLS bool
// FinalDataReply stores the final SMTP status line returned after the
// message body has been received. Empty value keeps the default accepted
// reply.
FinalDataReply string
}
// SMTPCapture stores one running local SMTP capture server together with the
// generated trust anchor used by external processes.
type SMTPCapture struct {
addr string
rootCAPath string
listener net.Listener
tlsConfig *tls.Config
connsMu sync.Mutex
conns map[net.Conn]struct{}
payloadsMu sync.Mutex
payloads []string
acceptWG sync.WaitGroup
connWG sync.WaitGroup
}
// StartSMTPCapture starts one local SMTP server suitable for black-box tests
// that need to observe captured message payloads.
func StartSMTPCapture(t testing.TB, cfg SMTPCaptureConfig) *SMTPCapture {
t.Helper()
if cfg.FinalDataReply == "" {
cfg.FinalDataReply = "250 2.0.0 accepted"
}
serverCertificate, rootCAPEM := newSMTPCertificates(t)
rootCAPath := filepath.Join(t.TempDir(), "smtp-root-ca.pem")
if err := os.WriteFile(rootCAPath, rootCAPEM, 0o600); err != nil {
t.Fatalf("write SMTP root CA: %v", err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("start SMTP capture listener: %v", err)
}
capture := &SMTPCapture{
addr: listener.Addr().String(),
rootCAPath: rootCAPath,
listener: listener,
tlsConfig: &tls.Config{
Certificates: []tls.Certificate{serverCertificate},
MinVersion: tls.VersionTLS12,
},
conns: make(map[net.Conn]struct{}),
}
capture.acceptWG.Add(1)
go func() {
defer capture.acceptWG.Done()
for {
conn, err := listener.Accept()
if err != nil {
return
}
capture.trackConn(conn)
capture.connWG.Add(1)
go func() {
defer capture.connWG.Done()
defer capture.untrackConn(conn)
defer func() {
_ = conn.Close()
}()
capture.serveConnection(conn, cfg)
}()
}
}()
t.Cleanup(func() {
_ = capture.listener.Close()
capture.closeConnections()
capture.acceptWG.Wait()
capture.connWG.Wait()
})
return capture
}
// Addr returns the externally reachable TCP address of the capture server.
func (capture *SMTPCapture) Addr() string {
if capture == nil {
return ""
}
return capture.addr
}
// RootCAPath returns the PEM path that should be trusted by clients talking to
// the capture server over STARTTLS.
func (capture *SMTPCapture) RootCAPath() string {
if capture == nil {
return ""
}
return capture.rootCAPath
}
// LatestPayload returns the most recently captured SMTP DATA payload.
func (capture *SMTPCapture) LatestPayload() string {
if capture == nil {
return ""
}
capture.payloadsMu.Lock()
defer capture.payloadsMu.Unlock()
if len(capture.payloads) == 0 {
return ""
}
return capture.payloads[len(capture.payloads)-1]
}
func (capture *SMTPCapture) trackConn(conn net.Conn) {
capture.connsMu.Lock()
defer capture.connsMu.Unlock()
capture.conns[conn] = struct{}{}
}
func (capture *SMTPCapture) untrackConn(conn net.Conn) {
capture.connsMu.Lock()
defer capture.connsMu.Unlock()
delete(capture.conns, conn)
}
func (capture *SMTPCapture) closeConnections() {
capture.connsMu.Lock()
defer capture.connsMu.Unlock()
for conn := range capture.conns {
_ = conn.Close()
}
}
func (capture *SMTPCapture) appendPayload(payload string) {
capture.payloadsMu.Lock()
defer capture.payloadsMu.Unlock()
capture.payloads = append(capture.payloads, payload)
}
func (capture *SMTPCapture) serveConnection(conn net.Conn, cfg SMTPCaptureConfig) {
reader := newSMTPLineReader(conn)
writer := newSMTPLineWriter(conn)
writer.writeLine("220 localhost ESMTP")
tlsActive := false
for {
line, err := reader.readLine()
if err != nil {
return
}
command := strings.ToUpper(line)
switch {
case strings.HasPrefix(command, "EHLO "), strings.HasPrefix(command, "HELO "):
if cfg.SupportsSTARTTLS && !tlsActive {
writer.writeLines(
"250-localhost",
"250-8BITMIME",
"250-STARTTLS",
"250 SMTPUTF8",
)
continue
}
writer.writeLines(
"250-localhost",
"250-8BITMIME",
"250 SMTPUTF8",
)
case command == "STARTTLS":
if !cfg.SupportsSTARTTLS {
writer.writeLine("454 4.7.0 TLS not available")
continue
}
writer.writeLine("220 Ready to start TLS")
tlsConn := tls.Server(conn, capture.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
return
}
capture.trackConn(tlsConn)
capture.untrackConn(conn)
conn = tlsConn
reader = newSMTPLineReader(conn)
writer = newSMTPLineWriter(conn)
tlsActive = true
case strings.HasPrefix(command, "MAIL FROM:"):
writer.writeLine("250 2.1.0 Ok")
case strings.HasPrefix(command, "RCPT TO:"):
writer.writeLine("250 2.1.5 Ok")
case command == "DATA":
writer.writeLine("354 End data with <CR><LF>.<CR><LF>")
var payload strings.Builder
for {
dataLine, err := reader.readRawLine()
if err != nil {
return
}
if dataLine == ".\r\n" {
break
}
payload.WriteString(dataLine)
}
capture.appendPayload(payload.String())
writer.writeLine(cfg.FinalDataReply)
case command == "RSET":
writer.writeLine("250 2.0.0 Ok")
case command == "QUIT":
writer.writeLine("221 2.0.0 Bye")
return
default:
writer.writeLine("250 2.0.0 Ok")
}
}
}
type smtpLineReader struct {
conn net.Conn
}
func newSMTPLineReader(conn net.Conn) *smtpLineReader {
return &smtpLineReader{conn: conn}
}
func (reader *smtpLineReader) readLine() (string, error) {
line, err := reader.readRawLine()
if err != nil {
return "", err
}
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
}
func (reader *smtpLineReader) readRawLine() (string, error) {
var buffer bytes.Buffer
tmp := make([]byte, 1)
for {
if _, err := reader.conn.Read(tmp); err != nil {
return "", err
}
buffer.WriteByte(tmp[0])
if tmp[0] == '\n' {
return buffer.String(), nil
}
}
}
type smtpLineWriter struct {
conn net.Conn
}
func newSMTPLineWriter(conn net.Conn) *smtpLineWriter {
return &smtpLineWriter{conn: conn}
}
func (writer *smtpLineWriter) writeLine(line string) {
_, _ = io.WriteString(writer.conn, line+"\r\n")
}
func (writer *smtpLineWriter) writeLines(lines ...string) {
for _, line := range lines {
writer.writeLine(line)
}
}
func newSMTPCertificates(t testing.TB) (tls.Certificate, []byte) {
t.Helper()
rootKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate SMTP root key: %v", err)
}
now := time.Now()
rootTemplate := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "galaxy-integration-smtp-root",
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
IsCA: true,
BasicConstraintsValid: true,
}
rootDER, err := x509.CreateCertificate(rand.Reader, &rootTemplate, &rootTemplate, &rootKey.PublicKey, rootKey)
if err != nil {
t.Fatalf("create SMTP root certificate: %v", err)
}
rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate SMTP server key: %v", err)
}
serverTemplate := x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
CommonName: "127.0.0.1",
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
rootCert, err := x509.ParseCertificate(rootDER)
if err != nil {
t.Fatalf("parse SMTP root certificate: %v", err)
}
serverDER, err := x509.CreateCertificate(rand.Reader, &serverTemplate, rootCert, &serverKey.PublicKey, rootKey)
if err != nil {
t.Fatalf("create SMTP server certificate: %v", err)
}
serverPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER})
serverKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(serverKey),
})
certificate, err := tls.X509KeyPair(append(serverPEM, rootPEM...), serverKeyPEM)
if err != nil {
t.Fatalf("load SMTP server key pair: %v", err)
}
return certificate, rootPEM
}
+834
View File
@@ -0,0 +1,834 @@
# Mail Service Implementation Plan
This plan has been already implemented and stays here for historical reasons.
It should NOT be threated as source of truth for service functionality.
## Summary
This plan describes the full v1 implementation path for `galaxy/mail`.
It is intentionally decision-complete: the implementer should not need to
invent service boundaries, storage layout, contracts, or retry semantics while
building the service.
The target outcome is one runnable internal service that:
- accepts auth-code mail synchronously over trusted REST
- consumes generic non-auth mail asynchronously from `Redis Streams`
- renders templates or accepts pre-rendered content
- delivers through SMTP or a deterministic stub
- retries bounded transient failures
- stores durable delivery audit state
- exposes trusted operator reads and resend controls
## Global Rules
- keep one logical delivery equal to one SMTP envelope
- keep `suppressed` separate from failure
- require explicit idempotency for every accepted command
- prefer deterministic Redis-backed scheduling over in-memory timers
- keep operator inspection possible without direct Redis access
- treat filesystem templates as the v1 source of truth
- keep public and trusted contracts explicit and versionable
## Target Runtime Layout
```text
mail/
├── cmd/
│ └── mail/
│ └── main.go
├── internal/
│ ├── app/
│ │ ├── app.go
│ │ ├── bootstrap.go
│ │ └── runtime.go
│ ├── config/
│ │ ├── config.go
│ │ ├── env.go
│ │ └── validation.go
│ ├── domain/
│ │ ├── delivery/
│ │ │ ├── model.go
│ │ │ ├── state.go
│ │ │ └── errors.go
│ │ ├── attempt/
│ │ │ ├── model.go
│ │ │ ├── state.go
│ │ │ └── policy.go
│ │ ├── idempotency/
│ │ │ └── model.go
│ │ ├── template/
│ │ │ ├── model.go
│ │ │ └── locale.go
│ │ └── common/
│ │ ├── email.go
│ │ ├── locale.go
│ │ ├── attachment.go
│ │ └── ids.go
│ ├── ports/
│ │ ├── deliverystore.go
│ │ ├── attemptstore.go
│ │ ├── idempotencystore.go
│ │ ├── commandsubscriber.go
│ │ ├── attemptscheduler.go
│ │ ├── templatecatalog.go
│ │ ├── provider.go
│ │ ├── clock.go
│ │ └── idgenerator.go
│ ├── service/
│ │ ├── acceptauthdelivery/
│ │ ├── acceptgenericdelivery/
│ │ ├── executeattempt/
│ │ ├── listdeliveries/
│ │ ├── getdelivery/
│ │ ├── listattempts/
│ │ └── resenddelivery/
│ ├── api/
│ │ ├── internalhttp/
│ │ └── streamcommand/
│ ├── adapters/
│ │ ├── redis/
│ │ ├── smtp/
│ │ ├── templates/
│ │ ├── stubprovider/
│ │ ├── clock/
│ │ └── id/
│ ├── worker/
│ │ ├── command_consumer.go
│ │ ├── scheduler.go
│ │ ├── attempt_worker.go
│ │ └── cleanup_worker.go
│ ├── observability/
│ │ ├── logging.go
│ │ ├── metrics.go
│ │ └── tracing.go
│ └── testkit/
│ ├── redis.go
│ ├── provider.go
│ ├── clock.go
│ ├── templates.go
│ └── commands.go
├── templates/
│ └── ...
├── docs/
│ ├── README.md
│ └── stage-01-vocabulary-and-ownership.md
├── README.md
└── PLAN.md
```
## Target Configuration
Planned environment variables:
- `MAIL_INTERNAL_HTTP_ADDR` with default `:8080`
- `MAIL_REDIS_ADDR` required
- `MAIL_REDIS_COMMAND_STREAM` with default `mail:delivery_commands`
- `MAIL_REDIS_ATTEMPT_SCHEDULE_KEY` with default `mail:attempt_schedule`
- `MAIL_REDIS_DEAD_LETTER_PREFIX` with default `mail:dead_letters:`
- `MAIL_SMTP_MODE=stub|smtp` with default `stub`
- `MAIL_SMTP_ADDR` required in `smtp` mode
- `MAIL_SMTP_USERNAME` optional
- `MAIL_SMTP_PASSWORD` optional
- `MAIL_SMTP_FROM_EMAIL` required in `smtp` mode
- `MAIL_SMTP_FROM_NAME` optional
- `MAIL_SMTP_TIMEOUT` with default `15s`
- `MAIL_TEMPLATE_DIR` with default `templates`
- `MAIL_ATTEMPT_WORKER_CONCURRENCY` with default `4`
- `MAIL_STREAM_BLOCK_TIMEOUT` with default `2s`
- `MAIL_OPERATOR_REQUEST_TIMEOUT` with default `5s`
- `MAIL_IDEMPOTENCY_TTL` with default `168h`
- `MAIL_DELIVERY_TTL` with default `720h`
- `MAIL_ATTEMPT_TTL` with default `2160h`
## ~~Stage 01.~~ Freeze Vocabulary and Ownership
Status: implemented.
### Goal
Freeze the service vocabulary and remove cross-service ambiguity before any
implementation work starts.
### Tasks
- Freeze that `Mail Service` owns delivery acceptance, attempts, retry,
suppression, audit, and resend.
- Freeze that `Notification Service` owns the business decision to request
non-auth mail.
- Freeze that `Auth / Session Service` uses the dedicated auth REST contract.
- Freeze that `Geo Profile Service` routes optional admin mail through
`Notification Service`, not directly to `Mail Service`.
- Freeze that operator APIs are part of v1, not a later add-on.
### Artifacts
- stable service README
- aligned architecture references
- list of accepted source values:
- `authsession`
- `notification`
- `operator_resend`
### Exit Criteria
- no document still treats `Geo Profile Service` as a direct `Mail Service`
caller
- no document claims that all `Mail Service` callers use the same transport
### Targeted Tests
- documentation review only
## ~~Stage 02.~~ Define the Domain Model and State Rules
Status: implemented.
### Goal
Describe the logical delivery entities and freeze their valid state
transitions.
### Tasks
- Define `mail_delivery`, `mail_attempt`, `mail_idempotency_record`,
`mail_template`, and `mail_dead_letter_entry`.
- Freeze delivery states:
- `accepted`
- `queued`
- `rendered`
- `sending`
- `sent`
- `suppressed`
- `failed`
- `dead_letter`
- Freeze attempt states:
- `scheduled`
- `in_progress`
- `provider_accepted`
- `provider_rejected`
- `transport_failed`
- `timed_out`
- Freeze resend as clone-only with immutable parent history.
- Freeze terminal-state resend eligibility:
- `sent`
- `suppressed`
- `failed`
- `dead_letter`
### Artifacts
- domain models
- state transition table
- resend eligibility rules
### Exit Criteria
- every use case can rely on one explicit state machine
### Targeted Tests
- unit tests for allowed and forbidden delivery transitions
- unit tests for resend eligibility
## ~~Stage 03.~~ Freeze the Redis Physical Model
Status: implemented.
### Goal
Lock the Redis layout so repository and scheduling adapters can be implemented
without revisiting the data design.
### Tasks
- Freeze primary keys:
- `mail:deliveries:<delivery_id>`
- `mail:attempts:<delivery_id>:<attempt_no>`
- `mail:idempotency:<source>:<idempotency_key>`
- `mail:dead_letters:<delivery_id>`
- Freeze scheduler and ingress keys:
- `mail:delivery_commands`
- `mail:attempt_schedule`
- Freeze search indexes:
- `mail:idx:recipient:<email>`
- `mail:idx:status:<status>`
- `mail:idx:source:<source>`
- `mail:idx:template:<template_id>`
- `mail:idx:idempotency:<source>:<idempotency_key>`
- `mail:idx:created_at`
- Freeze storage format:
- canonical JSON blob in Redis string keys for delivery and attempt records
- sorted-set indexes scored by `created_at_ms`
- Explicitly reject Redis storage for template contents in v1 because the
template catalog is filesystem-backed.
- Freeze retention:
- idempotency `7d`
- delivery `30d`
- attempts and dead letters `90d`
- Freeze atomic write boundaries:
- reserve idempotency
- store delivery
- schedule first attempt
- create resend clone
### Artifacts
- Redis key catalog
- atomicity notes for Lua or optimistic transaction usage
- retention and cleanup notes
### Exit Criteria
- the Redis adapters can be implemented without unresolved naming or
transactional questions
### Targeted Tests
- repository tests for key naming
- atomicity tests for duplicate idempotency races
- cleanup tests for TTL-driven record expiry
## ~~Stage 04.~~ Freeze the Auth REST Contract
Status: implemented.
### Goal
Define the direct trusted contract from `Auth / Session Service`.
### Tasks
- Freeze route `POST /api/v1/internal/login-code-deliveries`.
- Freeze required `Idempotency-Key` header.
- Freeze body fields:
- `email`
- `code`
- `locale`
- Freeze success outcomes:
- `sent`
- `suppressed`
- Freeze trusted error codes:
- `invalid_request`
- `internal_error`
- `service_unavailable`
- Freeze the meaning of `sent` as durable acceptance into the mail pipeline,
not immediate SMTP completion.
- Freeze auth-client behavior of no automatic retry on upstream or transport
failures.
### Artifacts
- request/response DTOs
- handler contract notes
- error mapping table
### Exit Criteria
- the auth REST client and server can be built from the frozen contract
### Targeted Tests
- strict JSON decoding tests
- required header validation tests
- idempotent repeat request tests
- sent versus suppressed response tests
## ~~Stage 05.~~ Freeze the Async Generic Contract
Status: implemented.
### Goal
Define the exact `Redis Streams` command format used by
`Notification Service`.
### Tasks
- Freeze the stream name `mail:delivery_commands`.
- Freeze required fields:
- `delivery_id`
- `source`
- `payload_mode`
- `idempotency_key`
- `requested_at_ms`
- `payload_json`
- Freeze optional fields:
- `request_id`
- `trace_id`
- Freeze that async `source` accepts only:
- `notification`
- Freeze payload modes:
- `rendered`
- `template`
- Freeze the rendered payload shape with:
- recipient envelope
- `subject`
- `text_body`
- optional `html_body`
- attachments
- Freeze the template payload shape with:
- recipient envelope
- `template_id`
- `locale`
- `variables`
- attachments
- Freeze duplicate handling by `(source, idempotency_key)`.
- Freeze `request_id` and `trace_id` as tracing-only metadata excluded from
the idempotency fingerprint.
- Freeze the malformed-command path into dedicated operator-visible
`mail_malformed_command_entry` state outside `mail_delivery`.
### Artifacts
- stream field catalog
- typed stream command contract
- `AsyncAPI` specification
- `payload_json` schema notes
- malformed command handling rules
### Exit Criteria
- `Notification Service` can publish one command without needing a follow-up
design round
### Targeted Tests
- strict stream-entry decoding tests
- duplicate idempotency tests
- malformed command recording-contract tests
- rendered and template payload acceptance tests
## ~~Stage 06.~~ Build the Runnable Service Skeleton
Status: implemented.
### Goal
Create one runnable internal process with config, Redis, HTTP server, and
workers.
### Tasks
- Implement `cmd/mail`.
- Implement config loading and validation.
- Wire Redis client, template catalog, provider adapter, HTTP server, and
workers.
- Add graceful shutdown across:
- HTTP server
- stream consumer
- scheduler
- attempt workers
- cleanup worker
- Add startup validation for required Redis and provider config.
### Artifacts
- runnable `cmd/mail`
- bootstrap wiring
- graceful shutdown logic
### Exit Criteria
- the process starts and stops cleanly with valid config
### Targeted Tests
- startup with stub mode
- startup failure on invalid Redis config
- graceful shutdown without leaked goroutines
## ~~Stage 07.~~ Implement Auth Delivery Acceptance
Status: implemented.
### Goal
Accept auth-code deliveries synchronously and durably.
### Tasks
- Implement the auth acceptance use case.
- Validate `email`, `code`, `locale`, and `Idempotency-Key`.
- Classify explicit suppression without treating it as failure.
- Persist delivery, idempotency record, and first scheduled attempt
atomically.
- Keep `suppressed` acceptance as the explicit exception that persists only
delivery plus idempotency state without a first attempt.
- Return stable `sent` or `suppressed`.
- Add telemetry for accepted auth requests.
- Reject mismatched replays with the same idempotency key.
### Artifacts
- auth acceptance service
- internal HTTP handler
- DTO validation and error mapping
### Exit Criteria
- auth requests create one durable delivery or fail closed without partial
state
### Targeted Tests
- valid request accepted as `sent`
- valid request accepted as `suppressed` without attempt scheduling
- duplicate identical request returns same result
- duplicate mismatched request is rejected
- Redis persistence failure surfaces `503 service_unavailable`
## ~~Stage 08.~~ Implement Async Generic Acceptance
Status: implemented.
### Goal
Consume generic mail commands from `Redis Streams` and convert them into
durable deliveries.
### Tasks
- Implement plain `XREAD`-based stream consumption.
- Decode and validate stream entries.
- Persist one delivery and schedule one first attempt atomically.
- Advance the consumer offset only after durable acceptance.
- Meter malformed entries and record them as operator-visible
`mail_malformed_command_entry` state.
- Keep duplicate idempotency requests as no-op accepts.
### Artifacts
- stream consumer worker
- generic acceptance service
- malformed command recorder
### Exit Criteria
- valid commands are never lost after they are read from the stream
### Targeted Tests
- rendered command acceptance
- template command acceptance
- duplicate command no-op behavior
- malformed command recording
- consumer restart continuing from the correct offset
## ~~Stage 09.~~ Implement the Template Catalog and Rendering
Status: implemented.
### Goal
Provide deterministic rendering for template-mode deliveries.
### Tasks
- Implement filesystem-backed template discovery under `templates/`.
- Freeze directory layout as `<template_id>/<locale>/subject.tmpl`,
`text.tmpl`, and optional `html.tmpl`.
- Implement locale validation and fallback to `en`.
- Record `locale_fallback_used`.
- Validate required variables before rendering.
- Reject unknown missing required variables deterministically.
- Add dedicated auth template family:
- `auth.login_code`
### Artifacts
- template catalog adapter
- renderer
- auth template assets
### Exit Criteria
- template mode always produces one final deterministic subject/body bundle or
one classified render failure
### Targeted Tests
- exact locale render
- unsupported locale fallback to `en`
- missing `en` fallback failure
- missing required variable failure
- deterministic render snapshots
## ~~Stage 10.~~ Implement the Provider Layer
Status: implemented.
### Goal
Provide concrete delivery adapters for SMTP and deterministic local testing.
### Tasks
- Freeze provider result classifications:
- `accepted`
- `suppressed`
- `transient_failure`
- `permanent_failure`
- Implement SMTP adapter with:
- dial/connect
- optional auth
- envelope mapping
- MIME body construction
- inline attachment mapping
- timeout classification
- Implement stub adapter with scriptable outcomes.
- Redact provider summaries before storing them in audit fields.
### Artifacts
- SMTP adapter
- stub provider adapter
- MIME builder helpers
### Exit Criteria
- one attempt can be executed against either adapter with stable classified
outcomes
### Targeted Tests
- SMTP request construction tests
- attachment mapping tests
- timeout classification tests
- stub scripted outcome tests
## ~~Stage 11.~~ Implement the Attempt Scheduler and Workers
Status: implemented.
### Goal
Run due attempts exactly once per scheduled slot and apply retry policy.
### Tasks
- Implement `mail:attempt_schedule` claim logic.
- Enforce at most one active attempt per delivery.
- Execute provider calls through the attempt service.
- Schedule retries at:
- `1m`
- `5m`
- `30m`
- Transition exhausted deliveries to `dead_letter`.
- Keep recoverable state across process restarts.
- Ensure claimed but unfinished work becomes visible again after worker crash
recovery.
### Artifacts
- scheduler worker
- attempt worker
- retry planner
- dead-letter writer
### Exit Criteria
- the service survives restarts and resumes scheduled work without duplicate
attempt ownership
### Targeted Tests
- immediate first attempt
- transient retry chain to success
- retry exhaustion to dead letter
- crash recovery of in-progress attempt ownership
## ~~Stage 12.~~ Implement the Operator API
Status: implemented.
### Goal
Provide trusted read and resend controls without direct Redis access.
### Tasks
- Implement delivery lookup by `delivery_id`.
- Implement filtered list with deterministic cursor pagination.
- Implement attempt history reads.
- Implement resend clone creation.
- Freeze cursor format as opaque base64 of `created_at_ms:delivery_id`.
- Reject resend for non-terminal statuses.
### Artifacts
- operator HTTP handlers
- list query DTOs
- resend service
### Exit Criteria
- operators can inspect and resend deliveries safely through the service API
### Targeted Tests
- list filtering by recipient, status, source, template, and idempotency key
- cursor pagination tests
- resend allowed for terminal states only
- resend creates a linked clone rather than mutating the original
## ~~Stage 13.~~ Add Observability and Runbook Coverage
Status: implemented.
### Goal
Make the service operable without reading the code.
### Tasks
- Add counters for:
- accepted auth deliveries
- accepted generic deliveries
- suppressed deliveries
- delivery statuses
- attempt outcomes
- dead letters
- locale fallback
- Add gauges or histograms for:
- scheduled depth
- oldest scheduled age
- SMTP latency
- Add structured logs with:
- `delivery_id`
- `source`
- `template_id`
- `attempt_no`
- Add traces around:
- acceptance
- rendering
- provider send
- resend
- Write operator runbook content for:
- backlog growth
- dead-letter spikes
- repeated suppressions
- SMTP auth or timeout failures
- malformed stream commands
### Artifacts
- telemetry runtime
- logging helpers
- runbook section drafts
### Exit Criteria
- common failure modes are visible and actionable
### Targeted Tests
- metric emission tests
- log field presence tests
- trace smoke tests where practical
## ~~Stage 14.~~ Complete the Test Matrix
Status: implemented.
### Goal
Reach a safe verification baseline across unit, integration, and end-to-end
scenarios.
### Tasks
- Add unit tests for:
- validation
- state transitions
- idempotency
- rendering
- provider classification
- retry planning
- Add integration tests for:
- auth REST to durable delivery
- stream command to durable delivery
- attempt execution against stub provider
- operator API against Redis-backed state
- Add end-to-end scenarios for:
- auth `sent`
- auth `suppressed`
- template locale fallback
- transient retry to success
- retry exhaustion to dead letter
- duplicate idempotency key
- resend clone
- graceful shutdown with pending work
### Artifacts
- unit test suite
- integration harness
- end-to-end scenarios
### Exit Criteria
- the planned behavior is covered closely enough to refactor safely
### Targeted Tests
- execute the smallest relevant subset:
- `go test ./mail/...`
- focused integration packages once they exist
## ~~Stage 15.~~ Align Cross-Service Documentation
Status: implemented.
### Goal
Update existing documentation so the repository tells one coherent story about
`Mail Service`.
### Tasks
- Update `ARCHITECTURE.md`:
- direct auth mail is synchronous trusted REST
- generic notification mail is asynchronous through `Notification Service`
- clarify that durable acceptance may precede SMTP completion
- Update `geoprofile` docs:
- remove direct `Geo Profile Service -> Mail Service`
- route optional admin mail through `Notification Service`
- Update `authsession` docs:
- clarify localized mail acceptance semantics
- clarify that `sent` means accepted into the mail pipeline
- Update `gateway` docs:
- document `Accept-Language` as the public auth locale source
- keep JSON bodies unchanged
- Update `user` docs:
- document the auth-provided preferred-language candidate rule for new-user
creation
### Artifacts
- aligned service READMEs and docs
- aligned architecture narrative
### Exit Criteria
- no first-class document contradicts the new `Mail Service` model
### Targeted Tests
- documentation review
- contract-document sync review
## Final Acceptance Checklist
The implementation is complete only when all of the following hold:
- the process starts with Redis and stub provider config
- auth REST intake works with explicit idempotency
- async generic stream intake works with duplicate suppression
- template rendering and locale fallback are deterministic
- SMTP and stub providers both work through the same port
- retries and dead-letter flow operate after restarts
- operator reads and resend clone work
- metrics, logs, and traces cover the main failure modes
- repository documentation is aligned with the final service model
+460
View File
@@ -0,0 +1,460 @@
# Mail Service
`Mail Service` is the internal e-mail delivery service of Galaxy.
Canonical contracts:
- [Internal REST API](api/internal-openapi.yaml)
- [Async generic command contract](api/delivery-commands-asyncapi.yaml)
- [Extended service docs](docs/README.md)
## Purpose
`Mail Service` owns durable intake, rendering, execution, retry, audit, and
operator recovery for outbound e-mail.
It does not decide whether a business event should become e-mail. That
decision belongs to `Notification Service`.
## Responsibility Boundaries
`Mail Service` is responsible for:
- direct auth-code mail intake from `Auth / Session Service`
- async generic mail intake from `Notification Service`
- validation of recipient envelope, payload shape, locale, and attachments
- deterministic template rendering for template-mode deliveries
- provider execution through `stub` or `smtp`
- retry scheduling, dead-letter escalation, and operator-visible audit state
- trusted operator reads and resend by clone creation
`Mail Service` is not responsible for:
- end-user authentication or authorization
- notification preference ownership
- deciding whether non-auth mail should be sent at all
- direct calls from `Geo Profile Service`
- hot-reloading templates or editing template catalog state at runtime
Cross-service routing rules:
- `Auth / Session Service -> Mail Service` is synchronous trusted REST
- `Notification Service -> Mail Service` is asynchronous `Redis Streams`
- `Geo Profile Service` must route optional admin e-mail through
`Notification Service`, not directly to `Mail Service`
## Runtime Surface
`cmd/mail` starts one internal-only process with:
- one trusted internal HTTP listener on `MAIL_INTERNAL_HTTP_ADDR`
- one async command consumer
- one attempt scheduler
- one attempt worker pool
- one cleanup worker
The service has no public ingress and no dedicated admin listener.
Intentional runtime omissions:
- no `/healthz`
- no `/readyz`
- no `/metrics`
Operational behavior:
- startup performs bounded Redis connectivity checks and fails fast on invalid
runtime configuration
- the template catalog is parsed once at startup and kept immutable for the
lifetime of the process
- template changes require process restart
- operator handlers execute under `MAIL_OPERATOR_REQUEST_TIMEOUT`
## Configuration
Required for all starts:
- `MAIL_REDIS_ADDR`
Primary configuration groups:
- process and logging:
- `MAIL_SHUTDOWN_TIMEOUT`
- `MAIL_LOG_LEVEL`
- internal HTTP:
- `MAIL_INTERNAL_HTTP_ADDR`
- `MAIL_INTERNAL_HTTP_READ_HEADER_TIMEOUT`
- `MAIL_INTERNAL_HTTP_READ_TIMEOUT`
- `MAIL_INTERNAL_HTTP_IDLE_TIMEOUT`
- Redis connectivity:
- `MAIL_REDIS_USERNAME`
- `MAIL_REDIS_PASSWORD`
- `MAIL_REDIS_DB`
- `MAIL_REDIS_TLS_ENABLED`
- `MAIL_REDIS_OPERATION_TIMEOUT`
- `MAIL_REDIS_COMMAND_STREAM`
- SMTP provider:
- `MAIL_SMTP_MODE=stub|smtp`
- `MAIL_SMTP_ADDR`
- `MAIL_SMTP_USERNAME`
- `MAIL_SMTP_PASSWORD`
- `MAIL_SMTP_FROM_EMAIL`
- `MAIL_SMTP_FROM_NAME`
- `MAIL_SMTP_TIMEOUT`
- `MAIL_SMTP_INSECURE_SKIP_VERIFY`
- template catalog:
- `MAIL_TEMPLATE_DIR`
- worker and operator behavior:
- `MAIL_ATTEMPT_WORKER_CONCURRENCY`
- `MAIL_STREAM_BLOCK_TIMEOUT`
- `MAIL_OPERATOR_REQUEST_TIMEOUT`
- OpenTelemetry:
- `OTEL_SERVICE_NAME`
- `OTEL_TRACES_EXPORTER`
- `OTEL_METRICS_EXPORTER`
- `OTEL_EXPORTER_OTLP_PROTOCOL`
- `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`
- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL`
- `MAIL_OTEL_STDOUT_TRACES_ENABLED`
- `MAIL_OTEL_STDOUT_METRICS_ENABLED`
Defaults worth knowing:
- `MAIL_INTERNAL_HTTP_ADDR=:8080`
- `MAIL_SMTP_MODE=stub`
- `MAIL_SMTP_TIMEOUT=15s`
Additional SMTP note:
- `MAIL_SMTP_INSECURE_SKIP_VERIFY=false` by default and is intended only for
local self-signed SMTP capture or similar non-production environments
- `MAIL_TEMPLATE_DIR=templates`
- `MAIL_ATTEMPT_WORKER_CONCURRENCY=4`
- `MAIL_STREAM_BLOCK_TIMEOUT=2s`
- `MAIL_OPERATOR_REQUEST_TIMEOUT=5s`
- `MAIL_SHUTDOWN_TIMEOUT=5s`
Current implementation caveats:
- `MAIL_REDIS_COMMAND_STREAM` is effective for the async command consumer
- `MAIL_REDIS_ATTEMPT_SCHEDULE_KEY` and `MAIL_REDIS_DEAD_LETTER_PREFIX` are
parsed but the Redis adapters still use the fixed keys
`mail:attempt_schedule` and `mail:dead_letters:<delivery_id>`
- `MAIL_IDEMPOTENCY_TTL`, `MAIL_DELIVERY_TTL`, and `MAIL_ATTEMPT_TTL` are
parsed but the Redis adapters still enforce fixed retentions of `7d`, `30d`,
and `90d`
## Stable Input Contracts
### 1. Auth delivery REST
Route:
- `POST /api/v1/internal/login-code-deliveries`
Headers:
- required `Idempotency-Key`
Request body:
- `email`
- `code`
- `locale`
Stable success outcomes:
- `sent`
- `suppressed`
Important semantics:
- `sent` means the request was durably accepted into the internal
mail-delivery pipeline
- `sent` does not mean that SMTP delivery has already completed
- new durable auth deliveries surface as:
- `queued` in `MAIL_SMTP_MODE=smtp`
- `suppressed` in `MAIL_SMTP_MODE=stub`
- duplicate replays with the same normalized request return the same stable
outcome
- mismatched replays on the same `(source, idempotency_key)` return
`409 conflict`
### 2. Async generic command intake
Ingress stream:
- `mail:delivery_commands`
Stable envelope fields:
- `delivery_id`
- `source`
- `payload_mode`
- `idempotency_key`
- `request_id`
- `trace_id`
- `payload_json`
Contract rules:
- async `source` is fixed to `notification`
- supported `payload_mode` values are `rendered` and `template`
- `request_id` and `trace_id` are observability-only metadata and do not
participate in idempotency fingerprinting
- malformed commands are metered, logged, and recorded as dedicated
malformed-command entries
- malformed commands do not create a durable delivery record
- stream offset advances only after durable acceptance or durable
malformed-command recording
### 3. Trusted operator REST
Routes:
- `GET /api/v1/internal/deliveries`
- `GET /api/v1/internal/deliveries/{delivery_id}`
- `GET /api/v1/internal/deliveries/{delivery_id}/attempts`
- `POST /api/v1/internal/deliveries/{delivery_id}/resend`
List filters:
- `recipient`
- `status`
- `source`
- `template_id`
- `idempotency_key`
- `from_created_at_ms`
- `to_created_at_ms`
- `limit`
- `cursor`
Stable list behavior:
- ordering is `created_at_ms DESC`, then `delivery_id DESC`
- cursor is an opaque base64url encoding of `created_at_ms:delivery_id`
- `idempotency_key` without `source` matches across all stable sources
Stable resend rules:
- resend is clone-only
- resend is allowed only for terminal delivery states
- resend creates a new delivery with `source=operator_resend`
- resend clones preserve audit history of the original instead of mutating it
## Delivery Model
### Source vocabulary
Stable `mail_delivery.source` values:
- `authsession`
- `notification`
- `operator_resend`
### Payload modes
Stable `mail_delivery.payload_mode` values:
- `rendered`
- `template`
Rules:
- `rendered` stores final `subject`, `text_body`, and optional `html_body`
- `template` stores `template_id`, canonical `locale`, and strict JSON-object
`template_variables`
- raw attachment bodies are stored separately from the delivery audit record
### Delivery statuses
Stable operator-visible `mail_delivery.status` values:
- `queued`
- `rendered`
- `sending`
- `sent`
- `suppressed`
- `failed`
- `dead_letter`
Status meanings:
- `queued`: durable intake completed and the next attempt is scheduled
- `rendered`: template content has been materialized
- `sending`: one worker currently owns the active attempt
- `sent`: provider accepted the envelope
- `suppressed`: delivery was intentionally skipped as a successful business
outcome
- `failed`: terminal failure without dead-letter escalation
- `dead_letter`: retry budget was exhausted and operator follow-up is required
Stable transition rules:
- newly accepted durable deliveries surface as `queued` or `suppressed`
- `queued -> rendered` is used only for `payload_mode=template`
- `queued|rendered -> sending` happens on successful claim
- `sending -> sent|suppressed|failed|queued|dead_letter` depends on provider
classification and retry policy
The internal type `delivery.StatusAccepted` still exists in code, but it is
not part of the stable public delivery-status vocabulary and is not emitted by
the current runtime.
### Attempt statuses
Stable `mail_attempt.status` values:
- `scheduled`
- `in_progress`
- `render_failed`
- `provider_accepted`
- `provider_rejected`
- `transport_failed`
- `timed_out`
Rules:
- there is at most one active `in_progress` attempt per delivery
- `render_failed` means template rendering failed before provider execution
- `provider_accepted` ends the delivery as `sent`
- `provider_rejected` is used for:
- provider-side suppression ending in `suppressed`
- permanent provider failure ending in `failed`
- retryable paths are expressed through:
- `transport_failed`
- `timed_out`
## Template and Locale Policy
Template layout:
- `<template_id>/<locale>/subject.tmpl`
- `<template_id>/<locale>/text.tmpl`
- optional `<template_id>/<locale>/html.tmpl`
Required auth fallback files:
- `auth.login_code/en/subject.tmpl`
- `auth.login_code/en/text.tmpl`
Rendering rules:
- the process loads the full catalog at startup
- exact locale match is attempted first
- the only fallback locale is `en`
- there are no intermediate reductions such as `fr-CA -> fr -> en`
- `locale_fallback_used=true` is stored durably when fallback is applied
- subject and text use `text/template`
- optional HTML uses `html/template`
- missing required variables and template lookup failures are classified into
stable render-failure codes
## Redis Logical Model
Primary keys:
- `mail:deliveries:<delivery_id>`
- `mail:attempts:<delivery_id>:<attempt_no>`
- `mail:idempotency:<source>:<idempotency_key>`
- `mail:dead_letters:<delivery_id>`
- `mail:delivery_payloads:<delivery_id>`
- `mail:malformed_commands:<stream_entry_id>`
- `mail:stream_offsets:<stream>`
Scheduling and ingress keys:
- `mail:delivery_commands`
- `mail:attempt_schedule`
Operator indexes:
- `mail:idx:recipient:<email>`
- `mail:idx:status:<status>`
- `mail:idx:source:<source>`
- `mail:idx:template:<template_id>`
- `mail:idx:idempotency:<source>:<idempotency_key>`
- `mail:idx:created_at`
- `mail:idx:malformed_command:created_at`
Storage rules:
- dynamic Redis key segments are base64url-encoded
- durable records are stored as strict JSON blobs
- timestamps are stored in Unix milliseconds
- raw attachment payloads are separated from audit metadata
- malformed async commands are stored idempotently by `stream_entry_id`
Current fixed retentions:
- idempotency: `7d`
- deliveries and payload audit: `30d`
- attempts and dead letters: `90d`
- malformed commands: `90d`
## Provider, Retry, and Failure Policy
Provider modes:
- `stub`
- `smtp`
SMTP rules:
- outbound SMTP requires `STARTTLS`
- servers without `STARTTLS` support are treated as permanent failure
- SMTP authentication is enabled only when both username and password are set
Retry ladder:
- attempt `1 -> 2`: `1m`
- attempt `2 -> 3`: `5m`
- attempt `3 -> 4`: `30m`
- after attempt `4`: `dead_letter`
Failure handling:
- retryable provider failures become `transport_failed` or `timed_out`, then
either reschedule or escalate to `dead_letter`
- permanent provider failures become `failed`
- render failures become `failed` with `render_failed`
- stale claimed work is recovered after `MAIL_SMTP_TIMEOUT + 30s`
## Observability
The runtime exports telemetry through configured OpenTelemetry exporters only.
Main signals:
- `mail.delivery.accepted_auth`
- `mail.delivery.accepted_generic`
- `mail.delivery.suppressed`
- `mail.delivery.status_transitions`
- `mail.attempt.outcomes`
- `mail.delivery.dead_letters`
- `mail.template.locale_fallback`
- `mail.attempt_schedule.depth`
- `mail.attempt_schedule.oldest_age_ms`
- `mail.provider.send.duration_ms`
- `mail.stream_commands.malformed`
Additional behavior:
- internal HTTP uses `otelhttp`
- Redis clients use `redisotel`
- structured logs include `otel_trace_id` and `otel_span_id` when available
## Verification
Relevant commands:
- `cd mail && go test ./...`
- `cd integration && go test ./authsessionmail/...`
- `cd integration && go test ./gatewayauthsessionmail/...`
Extended references:
- [Runtime and components](docs/runtime.md)
- [Main flows](docs/flows.md)
- [Configuration and contract examples](docs/examples.md)
- [Operator runbook](docs/runbook.md)
+215
View File
@@ -0,0 +1,215 @@
asyncapi: 3.1.0
info:
title: Mail Service Async Generic Command Contract
version: 1.0.0
description: |
Stable contract for generic asynchronous delivery commands published by
Notification Service to Mail Service through Redis Streams.
channels:
deliveryCommands:
address: mail:delivery_commands
messages:
renderedDeliveryCommand:
$ref: '#/components/messages/RenderedDeliveryCommand'
templateDeliveryCommand:
$ref: '#/components/messages/TemplateDeliveryCommand'
operations:
publishDeliveryCommand:
action: send
channel:
$ref: '#/channels/deliveryCommands'
messages:
- $ref: '#/channels/deliveryCommands/messages/renderedDeliveryCommand'
- $ref: '#/channels/deliveryCommands/messages/templateDeliveryCommand'
components:
messages:
RenderedDeliveryCommand:
name: RenderedDeliveryCommand
title: Rendered delivery command
summary: Generic asynchronous delivery command with final rendered content.
payload:
$ref: '#/components/schemas/RenderedDeliveryCommandEnvelope'
examples:
- name: rendered
summary: Rendered delivery command example.
payload:
delivery_id: mail-123
source: notification
payload_mode: rendered
idempotency_key: notification:mail-123
requested_at_ms: "1775121700000"
request_id: req-123
trace_id: trace-123
payload_json: '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":["noreply@example.com"],"subject":"Turn ready","text_body":"Turn 54 is ready.","html_body":"<p>Turn 54 is ready.</p>","attachments":[{"filename":"report.txt","content_type":"text/plain","content_base64":"cmVwb3J0"}]}'
TemplateDeliveryCommand:
name: TemplateDeliveryCommand
title: Template delivery command
summary: Generic asynchronous delivery command with template rendering data.
payload:
$ref: '#/components/schemas/TemplateDeliveryCommandEnvelope'
examples:
- name: template
summary: Template delivery command example.
payload:
delivery_id: mail-124
source: notification
payload_mode: template
idempotency_key: notification:mail-124
requested_at_ms: "1775121700001"
payload_json: '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}'
schemas:
RenderedDeliveryCommandEnvelope:
type: object
additionalProperties: false
required:
- delivery_id
- source
- payload_mode
- idempotency_key
- requested_at_ms
- payload_json
properties:
delivery_id:
type: string
source:
type: string
const: notification
payload_mode:
type: string
const: rendered
idempotency_key:
type: string
requested_at_ms:
type: string
pattern: '^[0-9]+$'
request_id:
type: string
trace_id:
type: string
payload_json:
type: string
contentMediaType: application/json
contentSchema:
$ref: '#/components/schemas/RenderedPayloadJSON'
TemplateDeliveryCommandEnvelope:
type: object
additionalProperties: false
required:
- delivery_id
- source
- payload_mode
- idempotency_key
- requested_at_ms
- payload_json
properties:
delivery_id:
type: string
source:
type: string
const: notification
payload_mode:
type: string
const: template
idempotency_key:
type: string
requested_at_ms:
type: string
pattern: '^[0-9]+$'
request_id:
type: string
trace_id:
type: string
payload_json:
type: string
contentMediaType: application/json
contentSchema:
$ref: '#/components/schemas/TemplatePayloadJSON'
RenderedPayloadJSON:
type: object
additionalProperties: false
required:
- to
- cc
- bcc
- reply_to
- subject
- text_body
- attachments
properties:
to:
$ref: '#/components/schemas/EmailList'
cc:
$ref: '#/components/schemas/EmailList'
bcc:
$ref: '#/components/schemas/EmailList'
reply_to:
$ref: '#/components/schemas/EmailList'
subject:
type: string
minLength: 1
text_body:
type: string
minLength: 1
html_body:
type: string
attachments:
$ref: '#/components/schemas/AttachmentList'
TemplatePayloadJSON:
type: object
additionalProperties: false
required:
- to
- cc
- bcc
- reply_to
- template_id
- locale
- variables
- attachments
properties:
to:
$ref: '#/components/schemas/EmailList'
cc:
$ref: '#/components/schemas/EmailList'
bcc:
$ref: '#/components/schemas/EmailList'
reply_to:
$ref: '#/components/schemas/EmailList'
template_id:
type: string
minLength: 1
locale:
type: string
minLength: 1
variables:
type: object
additionalProperties: true
attachments:
$ref: '#/components/schemas/AttachmentList'
EmailList:
type: array
items:
type: string
format: email
AttachmentList:
type: array
maxItems: 5
items:
$ref: '#/components/schemas/Attachment'
Attachment:
type: object
additionalProperties: false
required:
- filename
- content_type
- content_base64
properties:
filename:
type: string
minLength: 1
content_type:
type: string
minLength: 1
content_base64:
type: string
description: Inline base64 payload. The sum of all attachment `content_base64` lengths must not exceed 2097152 bytes.
+725
View File
@@ -0,0 +1,725 @@
openapi: 3.0.3
info:
title: Galaxy Mail Service Internal REST API
version: v1
description: |
This specification documents the trusted internal REST contract of
`galaxy/mail`.
The current document freezes:
- the dedicated auth-delivery route used by `Auth / Session Service`;
- the trusted operator read and resend routes used for delivery audit and
recovery.
Contract rules:
- the internal surface lives under `/api/v1/internal`;
- request and response bodies are JSON only;
- auth-delivery intake requires the `Idempotency-Key` header;
- request bodies use strict JSON decoding with unknown-field rejection;
- trailing JSON input is rejected;
- success outcomes are limited to `sent` and `suppressed` on the auth
route;
- mismatched replays on the same `Idempotency-Key` return `409 conflict`;
- operator listing order is `created_at_ms DESC`, then `delivery_id DESC`;
- operator list cursors are opaque base64url encodings of
`created_at_ms:delivery_id`;
- `sent` means durable acceptance into the mail-delivery pipeline rather
than immediate SMTP completion;
- auth callers must not automatically retry transport or upstream
failures;
- `Auth / Session Service` sends the created `challenge_id` as the raw
`Idempotency-Key` header value.
servers:
- url: http://localhost:8080
description: Default local internal listener for Mail Service.
tags:
- name: AuthIntegration
description: Trusted auth-facing mail-delivery intake frozen for `Auth / Session Service`.
- name: OperatorIntegration
description: Trusted operator-facing delivery reads and resend controls.
paths:
/api/v1/internal/login-code-deliveries:
post:
tags:
- AuthIntegration
operationId: acceptLoginCodeDelivery
summary: Accept one auth login-code delivery request
description: |
Validates one trusted auth login-code delivery request and accepts it
durably into the internal mail-delivery pipeline or intentionally
suppresses outward delivery while keeping the auth flow success-shaped.
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/LoginCodeDeliveryRequest"
responses:
"200":
description: Stable auth-delivery acceptance outcome.
content:
application/json:
schema:
$ref: "#/components/schemas/LoginCodeDeliveryResponse"
"400":
$ref: "#/components/responses/InvalidRequestError"
"409":
$ref: "#/components/responses/ConflictError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
/api/v1/internal/deliveries:
get:
tags:
- OperatorIntegration
operationId: listDeliveries
summary: List durable deliveries for trusted operators
description: |
Returns one deterministic page of brief delivery summaries ordered by
`created_at_ms DESC`, then `delivery_id DESC`.
parameters:
- $ref: "#/components/parameters/RecipientFilter"
- $ref: "#/components/parameters/StatusFilter"
- $ref: "#/components/parameters/SourceFilter"
- $ref: "#/components/parameters/TemplateIDFilter"
- $ref: "#/components/parameters/IdempotencyKeyFilter"
- $ref: "#/components/parameters/FromCreatedAtMSFilter"
- $ref: "#/components/parameters/ToCreatedAtMSFilter"
- $ref: "#/components/parameters/ListLimit"
- $ref: "#/components/parameters/ListCursor"
responses:
"200":
description: One deterministic page of delivery summaries.
content:
application/json:
schema:
$ref: "#/components/schemas/DeliveryListResponse"
"400":
$ref: "#/components/responses/InvalidRequestError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
/api/v1/internal/deliveries/{delivery_id}:
get:
tags:
- OperatorIntegration
operationId: getDelivery
summary: Get one durable delivery for trusted operators
parameters:
- $ref: "#/components/parameters/DeliveryIDPath"
responses:
"200":
description: One full delivery view with the optional dead-letter entry.
content:
application/json:
schema:
$ref: "#/components/schemas/DeliveryDetailResponse"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/DeliveryNotFoundError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
/api/v1/internal/deliveries/{delivery_id}/attempts:
get:
tags:
- OperatorIntegration
operationId: listDeliveryAttempts
summary: Get the attempt history of one durable delivery
parameters:
- $ref: "#/components/parameters/DeliveryIDPath"
responses:
"200":
description: The ordered attempt history of one durable delivery.
content:
application/json:
schema:
$ref: "#/components/schemas/DeliveryAttemptsResponse"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/DeliveryNotFoundError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
/api/v1/internal/deliveries/{delivery_id}/resend:
post:
tags:
- OperatorIntegration
operationId: resendDelivery
summary: Clone one terminal delivery for resend
parameters:
- $ref: "#/components/parameters/DeliveryIDPath"
responses:
"200":
description: The clone delivery was created successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/DeliveryResendResponse"
"400":
$ref: "#/components/responses/InvalidRequestError"
"404":
$ref: "#/components/responses/DeliveryNotFoundError"
"409":
$ref: "#/components/responses/ResendNotAllowedError"
"500":
$ref: "#/components/responses/InternalError"
"503":
$ref: "#/components/responses/ServiceUnavailableError"
components:
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: |
Caller-owned stable deduplication key. `Auth / Session Service` uses
the created `challenge_id` as the raw header value.
schema:
type: string
DeliveryIDPath:
name: delivery_id
in: path
required: true
description: Mail Service delivery identifier.
schema:
type: string
RecipientFilter:
name: recipient
in: query
required: false
description: Effective-recipient filter covering `to`, `cc`, and `bcc`.
schema:
type: string
StatusFilter:
name: status
in: query
required: false
description: Delivery lifecycle status filter.
schema:
type: string
enum:
- queued
- rendered
- sending
- sent
- suppressed
- failed
- dead_letter
SourceFilter:
name: source
in: query
required: false
description: Delivery source filter.
schema:
type: string
enum:
- authsession
- notification
- operator_resend
TemplateIDFilter:
name: template_id
in: query
required: false
description: Template family filter.
schema:
type: string
IdempotencyKeyFilter:
name: idempotency_key
in: query
required: false
description: |
Idempotency-key filter. When `source` is omitted, Mail Service matches
the key across all frozen sources.
schema:
type: string
FromCreatedAtMSFilter:
name: from_created_at_ms
in: query
required: false
description: Inclusive lower bound for `created_at_ms`.
schema:
type: integer
format: int64
ToCreatedAtMSFilter:
name: to_created_at_ms
in: query
required: false
description: Inclusive upper bound for `created_at_ms`.
schema:
type: integer
format: int64
ListLimit:
name: limit
in: query
required: false
description: |
Maximum number of returned deliveries. The frozen default is `50` and
the maximum is `200`.
schema:
type: integer
minimum: 1
maximum: 200
ListCursor:
name: cursor
in: query
required: false
description: |
Opaque continuation cursor encoded as base64url of
`created_at_ms:delivery_id`.
schema:
type: string
schemas:
LoginCodeDeliveryRequest:
type: object
additionalProperties: false
required:
- email
- code
- locale
properties:
email:
type: string
description: Normalized destination e-mail address.
code:
type: string
description: Exact login code generated by `Auth / Session Service`.
locale:
type: string
description: Canonical BCP 47 language tag already resolved upstream.
LoginCodeDeliveryResponse:
type: object
additionalProperties: false
required:
- outcome
properties:
outcome:
type: string
description: Stable coarse outcome of the auth-delivery acceptance.
enum:
- sent
- suppressed
DeliverySummaryResponse:
type: object
additionalProperties: false
required:
- delivery_id
- source
- payload_mode
- to
- cc
- bcc
- reply_to
- locale_fallback_used
- idempotency_key
- status
- attempt_count
- created_at_ms
- updated_at_ms
properties:
delivery_id:
type: string
resend_parent_delivery_id:
type: string
source:
type: string
enum:
- authsession
- notification
- operator_resend
payload_mode:
type: string
enum:
- rendered
- template
template_id:
type: string
to:
type: array
items:
type: string
cc:
type: array
items:
type: string
bcc:
type: array
items:
type: string
reply_to:
type: array
items:
type: string
locale:
type: string
locale_fallback_used:
type: boolean
idempotency_key:
type: string
status:
type: string
enum:
- queued
- rendered
- sending
- sent
- suppressed
- failed
- dead_letter
attempt_count:
type: integer
last_attempt_status:
type: string
enum:
- scheduled
- in_progress
- render_failed
- provider_accepted
- provider_rejected
- transport_failed
- timed_out
provider_summary:
type: string
created_at_ms:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
sent_at_ms:
type: integer
format: int64
suppressed_at_ms:
type: integer
format: int64
failed_at_ms:
type: integer
format: int64
dead_lettered_at_ms:
type: integer
format: int64
DeliveryListResponse:
type: object
additionalProperties: false
required:
- items
properties:
items:
type: array
items:
$ref: "#/components/schemas/DeliverySummaryResponse"
next_cursor:
type: string
AttachmentResponse:
type: object
additionalProperties: false
required:
- filename
- content_type
- size_bytes
properties:
filename:
type: string
content_type:
type: string
size_bytes:
type: integer
format: int64
DeadLetterResponse:
type: object
additionalProperties: false
required:
- delivery_id
- final_attempt_no
- failure_classification
- created_at_ms
properties:
delivery_id:
type: string
final_attempt_no:
type: integer
failure_classification:
type: string
provider_summary:
type: string
created_at_ms:
type: integer
format: int64
recovery_hint:
type: string
DeliveryDetailResponse:
type: object
additionalProperties: false
required:
- delivery_id
- source
- payload_mode
- to
- cc
- bcc
- reply_to
- attachments
- locale_fallback_used
- idempotency_key
- status
- attempt_count
- created_at_ms
- updated_at_ms
properties:
delivery_id:
type: string
resend_parent_delivery_id:
type: string
source:
type: string
enum:
- authsession
- notification
- operator_resend
payload_mode:
type: string
enum:
- rendered
- template
template_id:
type: string
template_variables:
type: object
additionalProperties: true
to:
type: array
items:
type: string
cc:
type: array
items:
type: string
bcc:
type: array
items:
type: string
reply_to:
type: array
items:
type: string
subject:
type: string
text_body:
type: string
html_body:
type: string
attachments:
type: array
items:
$ref: "#/components/schemas/AttachmentResponse"
locale:
type: string
locale_fallback_used:
type: boolean
idempotency_key:
type: string
status:
type: string
enum:
- queued
- rendered
- sending
- sent
- suppressed
- failed
- dead_letter
attempt_count:
type: integer
last_attempt_status:
type: string
enum:
- scheduled
- in_progress
- render_failed
- provider_accepted
- provider_rejected
- transport_failed
- timed_out
provider_summary:
type: string
created_at_ms:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
sent_at_ms:
type: integer
format: int64
suppressed_at_ms:
type: integer
format: int64
failed_at_ms:
type: integer
format: int64
dead_lettered_at_ms:
type: integer
format: int64
dead_letter:
$ref: "#/components/schemas/DeadLetterResponse"
AttemptResponse:
type: object
additionalProperties: false
required:
- delivery_id
- attempt_no
- scheduled_for_ms
- status
properties:
delivery_id:
type: string
attempt_no:
type: integer
scheduled_for_ms:
type: integer
format: int64
started_at_ms:
type: integer
format: int64
finished_at_ms:
type: integer
format: int64
status:
type: string
enum:
- scheduled
- in_progress
- render_failed
- provider_accepted
- provider_rejected
- transport_failed
- timed_out
provider_classification:
type: string
provider_summary:
type: string
DeliveryAttemptsResponse:
type: object
additionalProperties: false
required:
- items
properties:
items:
type: array
items:
$ref: "#/components/schemas/AttemptResponse"
DeliveryResendResponse:
type: object
additionalProperties: false
required:
- delivery_id
properties:
delivery_id:
type: string
ErrorResponse:
type: object
additionalProperties: false
required:
- error
properties:
error:
$ref: "#/components/schemas/ErrorBody"
ErrorBody:
type: object
additionalProperties: false
required:
- code
- message
properties:
code:
type: string
description: Stable internal API error code.
message:
type: string
description: Human-readable trusted error message.
responses:
InvalidRequestError:
description: Request validation failed.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
missingHeader:
value:
error:
code: invalid_request
message: Idempotency-Key header must not be empty
invalidCursor:
value:
error:
code: invalid_request
message: cursor is invalid
ConflictError:
description: The current idempotency scope belongs to a different normalized request.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
conflict:
value:
error:
code: conflict
message: request conflicts with current state
DeliveryNotFoundError:
description: The requested delivery does not exist.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
missingDelivery:
value:
error:
code: delivery_not_found
message: delivery not found
ResendNotAllowedError:
description: The requested delivery is not in a terminal resend-eligible state.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
resendNotAllowed:
value:
error:
code: resend_not_allowed
message: delivery status does not allow resend
InternalError:
description: Internal application invariant failed.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
internal:
value:
error:
code: internal_error
message: internal server error
ServiceUnavailableError:
description: Durable acceptance or trusted delivery inspection could not be completed.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
examples:
unavailable:
value:
error:
code: service_unavailable
message: service is unavailable
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"galaxy/mail/internal/app"
"galaxy/mail/internal/config"
"galaxy/mail/internal/logging"
)
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "mail: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.LoadFromEnv()
if err != nil {
return err
}
logger, err := logging.New(cfg.Logging.Level)
if err != nil {
return err
}
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
runtime, err := app.NewRuntime(rootCtx, cfg, logger)
if err != nil {
return err
}
defer func() {
_ = runtime.Close()
}()
return runtime.Run(rootCtx)
}
+189
View File
@@ -0,0 +1,189 @@
package mail
import (
"os"
"path/filepath"
"runtime"
"testing"
"gopkg.in/yaml.v3"
"github.com/stretchr/testify/require"
)
func TestDeliveryCommandsAsyncAPISpecLoads(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t)
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
}
func TestDeliveryCommandsAsyncAPISpecFreezesChannelAndOperation(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t)
channel := getMapValue(t, doc, "channels", "deliveryCommands")
require.Equal(t, "mail:delivery_commands", getStringValue(t, channel, "address"))
channelMessages := getMapValue(t, channel, "messages")
require.Equal(t, "#/components/messages/RenderedDeliveryCommand", getStringValue(t, getMapValue(t, channelMessages, "renderedDeliveryCommand"), "$ref"))
require.Equal(t, "#/components/messages/TemplateDeliveryCommand", getStringValue(t, getMapValue(t, channelMessages, "templateDeliveryCommand"), "$ref"))
operation := getMapValue(t, doc, "operations", "publishDeliveryCommand")
require.Equal(t, "send", getStringValue(t, operation, "action"))
require.Equal(t, "#/channels/deliveryCommands", getStringValue(t, getMapValue(t, operation, "channel"), "$ref"))
messageRefs := getSliceValue(t, operation, "messages")
require.Len(t, messageRefs, 2)
require.Equal(t, "#/channels/deliveryCommands/messages/renderedDeliveryCommand", getStringValue(t, messageRefs[0].(map[string]any), "$ref"))
require.Equal(t, "#/channels/deliveryCommands/messages/templateDeliveryCommand", getStringValue(t, messageRefs[1].(map[string]any), "$ref"))
}
func TestDeliveryCommandsAsyncAPISpecFreezesEnvelopeSchemas(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t)
schemas := getMapValue(t, getMapValue(t, doc, "components"), "schemas")
renderedEnvelope := getMapValue(t, schemas, "RenderedDeliveryCommandEnvelope")
require.ElementsMatch(
t,
[]any{"delivery_id", "source", "payload_mode", "idempotency_key", "requested_at_ms", "payload_json"},
getSliceValue(t, renderedEnvelope, "required"),
)
require.Equal(t, "notification", getScalarValue(t, getMapValue(t, getMapValue(t, renderedEnvelope, "properties"), "source"), "const"))
require.Equal(t, "rendered", getScalarValue(t, getMapValue(t, getMapValue(t, renderedEnvelope, "properties"), "payload_mode"), "const"))
require.Equal(
t,
"#/components/schemas/RenderedPayloadJSON",
getStringValue(t, getMapValue(t, getMapValue(t, getMapValue(t, renderedEnvelope, "properties"), "payload_json"), "contentSchema"), "$ref"),
)
templateEnvelope := getMapValue(t, schemas, "TemplateDeliveryCommandEnvelope")
require.Equal(t, "notification", getScalarValue(t, getMapValue(t, getMapValue(t, templateEnvelope, "properties"), "source"), "const"))
require.Equal(t, "template", getScalarValue(t, getMapValue(t, getMapValue(t, templateEnvelope, "properties"), "payload_mode"), "const"))
require.Equal(
t,
"#/components/schemas/TemplatePayloadJSON",
getStringValue(t, getMapValue(t, getMapValue(t, getMapValue(t, templateEnvelope, "properties"), "payload_json"), "contentSchema"), "$ref"),
)
}
func TestDeliveryCommandsAsyncAPISpecFreezesPayloadSchemasAndExamples(t *testing.T) {
t.Parallel()
doc := loadAsyncAPISpec(t)
components := getMapValue(t, doc, "components")
schemas := getMapValue(t, components, "schemas")
messages := getMapValue(t, components, "messages")
renderedPayload := getMapValue(t, schemas, "RenderedPayloadJSON")
require.ElementsMatch(
t,
[]any{"to", "cc", "bcc", "reply_to", "subject", "text_body", "attachments"},
getSliceValue(t, renderedPayload, "required"),
)
templatePayload := getMapValue(t, schemas, "TemplatePayloadJSON")
require.ElementsMatch(
t,
[]any{"to", "cc", "bcc", "reply_to", "template_id", "locale", "variables", "attachments"},
getSliceValue(t, templatePayload, "required"),
)
attachment := getMapValue(t, schemas, "Attachment")
require.ElementsMatch(
t,
[]any{"filename", "content_type", "content_base64"},
getSliceValue(t, attachment, "required"),
)
renderedExamples := getSliceValue(t, getMapValue(t, messages, "RenderedDeliveryCommand"), "examples")
require.NotEmpty(t, getStringValue(t, getMapValue(t, renderedExamples[0].(map[string]any), "payload"), "payload_json"))
templateExamples := getSliceValue(t, getMapValue(t, messages, "TemplateDeliveryCommand"), "examples")
require.NotEmpty(t, getStringValue(t, getMapValue(t, templateExamples[0].(map[string]any), "payload"), "payload_json"))
}
func loadAsyncAPISpec(t *testing.T) map[string]any {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
require.FailNow(t, "runtime.Caller failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "api", "delivery-commands-asyncapi.yaml")
payload, err := os.ReadFile(specPath)
if err != nil {
require.Failf(t, "test failed", "read spec %s: %v", specPath, err)
}
var doc map[string]any
if err := yaml.Unmarshal(payload, &doc); err != nil {
require.Failf(t, "test failed", "decode spec %s: %v", specPath, err)
}
return doc
}
func getMapValue(t *testing.T, value map[string]any, path ...string) map[string]any {
t.Helper()
current := value
for _, segment := range path {
raw, ok := current[segment]
if !ok {
require.Failf(t, "test failed", "missing map key %s", segment)
}
next, ok := raw.(map[string]any)
if !ok {
require.Failf(t, "test failed", "value at %s is not a map", segment)
}
current = next
}
return current
}
func getStringValue(t *testing.T, value map[string]any, key string) string {
t.Helper()
raw, ok := value[key]
if !ok {
require.Failf(t, "test failed", "missing key %s", key)
}
result, ok := raw.(string)
if !ok {
require.Failf(t, "test failed", "value at %s is not a string", key)
}
return result
}
func getScalarValue(t *testing.T, value map[string]any, key string) any {
t.Helper()
raw, ok := value[key]
if !ok {
require.Failf(t, "test failed", "missing key %s", key)
}
return raw
}
func getSliceValue(t *testing.T, value map[string]any, key string) []any {
t.Helper()
raw, ok := value[key]
if !ok {
require.Failf(t, "test failed", "missing key %s", key)
}
result, ok := raw.([]any)
if !ok {
require.Failf(t, "test failed", "value at %s is not a slice", key)
}
return result
}
+283
View File
@@ -0,0 +1,283 @@
package mail
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
"runtime"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/stretchr/testify/require"
)
func TestInternalOpenAPISpecValidates(t *testing.T) {
t.Parallel()
loadSpec(t)
}
func TestInternalOpenAPISpecFreezesLoginCodeDeliveryContract(t *testing.T) {
t.Parallel()
doc := loadSpec(t)
operation := getOperation(t, doc, "/api/v1/internal/login-code-deliveries", http.MethodPost)
require.Equal(t, "acceptLoginCodeDelivery", operation.OperationID)
assertOperationParameterRefs(t, operation, "#/components/parameters/IdempotencyKey")
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/LoginCodeDeliveryRequest", "login-code-deliveries request schema")
assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusOK), "#/components/schemas/LoginCodeDeliveryResponse", "login-code-deliveries success schema")
assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusBadRequest), "#/components/schemas/ErrorResponse", "bad request schema")
assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusConflict), "#/components/schemas/ErrorResponse", "conflict schema")
assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusInternalServerError), "#/components/schemas/ErrorResponse", "internal error schema")
assertSchemaRef(t, responseSchemaRef(t, operation, http.StatusServiceUnavailable), "#/components/schemas/ErrorResponse", "service unavailable schema")
request := componentSchemaRef(t, doc, "LoginCodeDeliveryRequest")
assertRequiredFields(t, request, "email", "code", "locale")
response := componentSchemaRef(t, doc, "LoginCodeDeliveryResponse")
assertRequiredFields(t, response, "outcome")
assertStringEnum(t, response, "outcome", "sent", "suppressed")
}
func TestInternalOpenAPISpecFreezesOperatorContract(t *testing.T) {
t.Parallel()
doc := loadSpec(t)
listOperation := getOperation(t, doc, "/api/v1/internal/deliveries", http.MethodGet)
require.Equal(t, "listDeliveries", listOperation.OperationID)
assertOperationParameterRefs(
t,
listOperation,
"#/components/parameters/RecipientFilter",
"#/components/parameters/StatusFilter",
"#/components/parameters/SourceFilter",
"#/components/parameters/TemplateIDFilter",
"#/components/parameters/IdempotencyKeyFilter",
"#/components/parameters/FromCreatedAtMSFilter",
"#/components/parameters/ToCreatedAtMSFilter",
"#/components/parameters/ListLimit",
"#/components/parameters/ListCursor",
)
assertSchemaRef(t, responseSchemaRef(t, listOperation, http.StatusOK), "#/components/schemas/DeliveryListResponse", "deliveries list success schema")
deliveryGetOperation := getOperation(t, doc, "/api/v1/internal/deliveries/{delivery_id}", http.MethodGet)
require.Equal(t, "getDelivery", deliveryGetOperation.OperationID)
assertOperationParameterRefs(t, deliveryGetOperation, "#/components/parameters/DeliveryIDPath")
assertSchemaRef(t, responseSchemaRef(t, deliveryGetOperation, http.StatusOK), "#/components/schemas/DeliveryDetailResponse", "delivery get success schema")
attemptsOperation := getOperation(t, doc, "/api/v1/internal/deliveries/{delivery_id}/attempts", http.MethodGet)
require.Equal(t, "listDeliveryAttempts", attemptsOperation.OperationID)
assertOperationParameterRefs(t, attemptsOperation, "#/components/parameters/DeliveryIDPath")
assertSchemaRef(t, responseSchemaRef(t, attemptsOperation, http.StatusOK), "#/components/schemas/DeliveryAttemptsResponse", "delivery attempts success schema")
resendOperation := getOperation(t, doc, "/api/v1/internal/deliveries/{delivery_id}/resend", http.MethodPost)
require.Equal(t, "resendDelivery", resendOperation.OperationID)
assertOperationParameterRefs(t, resendOperation, "#/components/parameters/DeliveryIDPath")
assertSchemaRef(t, responseSchemaRef(t, resendOperation, http.StatusOK), "#/components/schemas/DeliveryResendResponse", "delivery resend success schema")
assertSchemaRef(t, responseSchemaRef(t, resendOperation, http.StatusConflict), "#/components/schemas/ErrorResponse", "delivery resend conflict schema")
listResponse := componentSchemaRef(t, doc, "DeliveryListResponse")
assertRequiredFields(t, listResponse, "items")
detailResponse := componentSchemaRef(t, doc, "DeliveryDetailResponse")
assertRequiredFields(t, detailResponse, "delivery_id", "source", "payload_mode", "to", "cc", "bcc", "reply_to", "attachments", "locale_fallback_used", "idempotency_key", "status", "attempt_count", "created_at_ms", "updated_at_ms")
attemptsResponse := componentSchemaRef(t, doc, "DeliveryAttemptsResponse")
assertRequiredFields(t, attemptsResponse, "items")
resendResponse := componentSchemaRef(t, doc, "DeliveryResendResponse")
assertRequiredFields(t, resendResponse, "delivery_id")
}
func TestInternalOpenAPISpecErrorExamplesMatchStableErrors(t *testing.T) {
t.Parallel()
doc := loadSpec(t)
require.JSONEq(
t,
`{"error":{"code":"invalid_request","message":"Idempotency-Key header must not be empty"}}`,
string(mustJSON(t, responseExampleValue(t, doc, "InvalidRequestError", "missingHeader"))),
)
require.JSONEq(
t,
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`,
string(mustJSON(t, responseExampleValue(t, doc, "ConflictError", "conflict"))),
)
require.JSONEq(
t,
`{"error":{"code":"internal_error","message":"internal server error"}}`,
string(mustJSON(t, responseExampleValue(t, doc, "InternalError", "internal"))),
)
require.JSONEq(
t,
`{"error":{"code":"service_unavailable","message":"service is unavailable"}}`,
string(mustJSON(t, responseExampleValue(t, doc, "ServiceUnavailableError", "unavailable"))),
)
require.JSONEq(
t,
`{"error":{"code":"delivery_not_found","message":"delivery not found"}}`,
string(mustJSON(t, responseExampleValue(t, doc, "DeliveryNotFoundError", "missingDelivery"))),
)
require.JSONEq(
t,
`{"error":{"code":"resend_not_allowed","message":"delivery status does not allow resend"}}`,
string(mustJSON(t, responseExampleValue(t, doc, "ResendNotAllowedError", "resendNotAllowed"))),
)
}
func loadSpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
require.FailNow(t, "runtime.Caller failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "api", "internal-openapi.yaml")
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
if err != nil {
require.Failf(t, "test failed", "load spec %s: %v", specPath, err)
}
if doc == nil {
require.Failf(t, "test failed", "load spec %s: returned nil document", specPath)
}
if err := doc.Validate(context.Background()); err != nil {
require.Failf(t, "test failed", "validate spec %s: %v", specPath, err)
}
return doc
}
func getOperation(t *testing.T, doc *openapi3.T, path string, method string) *openapi3.Operation {
t.Helper()
if doc.Paths == nil {
require.FailNow(t, "spec is missing paths")
}
pathItem := doc.Paths.Value(path)
if pathItem == nil {
require.Failf(t, "test failed", "spec is missing path %s", path)
}
operation := pathItem.GetOperation(method)
if operation == nil {
require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path)
}
return operation
}
func requestSchemaRef(t *testing.T, operation *openapi3.Operation) *openapi3.SchemaRef {
t.Helper()
if operation.RequestBody == nil || operation.RequestBody.Value == nil {
require.FailNow(t, "operation is missing request body")
}
mediaType := operation.RequestBody.Value.Content.Get("application/json")
if mediaType == nil || mediaType.Schema == nil {
require.FailNow(t, "operation is missing application/json request schema")
}
return mediaType.Schema
}
func responseSchemaRef(t *testing.T, operation *openapi3.Operation, status int) *openapi3.SchemaRef {
t.Helper()
responseRef := operation.Responses.Status(status)
if responseRef == nil || responseRef.Value == nil {
require.Failf(t, "test failed", "operation is missing %d response", status)
}
mediaType := responseRef.Value.Content.Get("application/json")
if mediaType == nil || mediaType.Schema == nil {
require.Failf(t, "test failed", "operation is missing application/json schema for %d response", status)
}
return mediaType.Schema
}
func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef {
t.Helper()
if doc.Components.Schemas == nil {
require.FailNow(t, "spec is missing component schemas")
}
schemaRef := doc.Components.Schemas[name]
if schemaRef == nil {
require.Failf(t, "test failed", "spec is missing component schema %s", name)
}
return schemaRef
}
func responseExampleValue(t *testing.T, doc *openapi3.T, responseName string, exampleName string) any {
t.Helper()
responseRef := doc.Components.Responses[responseName]
if responseRef == nil || responseRef.Value == nil {
require.Failf(t, "test failed", "spec is missing component response %s", responseName)
}
mediaType := responseRef.Value.Content.Get("application/json")
if mediaType == nil {
require.Failf(t, "test failed", "response %s is missing application/json content", responseName)
}
exampleRef := mediaType.Examples[exampleName]
if exampleRef == nil || exampleRef.Value == nil {
require.Failf(t, "test failed", "response %s is missing example %s", responseName, exampleName)
}
return exampleRef.Value.Value
}
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) {
t.Helper()
require.NotNil(t, schemaRef, "%s schema ref", name)
require.Equal(t, want, schemaRef.Ref, "%s schema ref", name)
}
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
t.Helper()
require.NotNil(t, schemaRef)
require.ElementsMatch(t, fields, schemaRef.Value.Required)
}
func assertStringEnum(t *testing.T, schemaRef *openapi3.SchemaRef, property string, values ...string) {
t.Helper()
require.NotNil(t, schemaRef)
propertyRef := schemaRef.Value.Properties[property]
require.NotNil(t, propertyRef, "schema property %s", property)
got := make([]string, 0, len(propertyRef.Value.Enum))
for _, value := range propertyRef.Value.Enum {
got = append(got, value.(string))
}
require.ElementsMatch(t, values, got)
}
func assertOperationParameterRefs(t *testing.T, operation *openapi3.Operation, refs ...string) {
t.Helper()
got := make([]string, 0, len(operation.Parameters))
for _, parameterRef := range operation.Parameters {
got = append(got, parameterRef.Ref)
}
require.ElementsMatch(t, refs, got)
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
payload, err := json.Marshal(value)
require.NoError(t, err)
return payload
}
+23
View File
@@ -0,0 +1,23 @@
# Mail Service Docs
This directory keeps service-local documentation that is more operational or
more example-heavy than [`../README.md`](../README.md).
Sections:
- [Runtime and components](runtime.md)
- [Main flows](flows.md)
- [Configuration and contract examples](examples.md)
- [Operator runbook](runbook.md)
Primary references:
- [`../README.md`](../README.md) for stable service scope, contracts, data
model, Redis layout, and retry policy
- [`../api/internal-openapi.yaml`](../api/internal-openapi.yaml) for the
trusted internal REST contract
- [`../api/delivery-commands-asyncapi.yaml`](../api/delivery-commands-asyncapi.yaml)
for the trusted async generic command contract
- [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) for system-level service
boundaries and transport rules
- [`../../TESTING.md`](../../TESTING.md) for the cross-service testing matrix
+129
View File
@@ -0,0 +1,129 @@
# Configuration and Contract Examples
The examples below are illustrative. IDs, timestamps, and keys are placeholders
unless explicitly stated otherwise.
## Example Environment
Minimal local runtime with stub provider:
```dotenv
MAIL_REDIS_ADDR=127.0.0.1:6379
MAIL_INTERNAL_HTTP_ADDR=:8080
MAIL_TEMPLATE_DIR=templates
MAIL_SMTP_MODE=stub
OTEL_TRACES_EXPORTER=none
OTEL_METRICS_EXPORTER=none
```
SMTP-backed shape:
```dotenv
MAIL_REDIS_ADDR=127.0.0.1:6379
MAIL_INTERNAL_HTTP_ADDR=:8080
MAIL_TEMPLATE_DIR=templates
MAIL_SMTP_MODE=smtp
MAIL_SMTP_ADDR=127.0.0.1:1025
MAIL_SMTP_FROM_EMAIL=noreply@example.com
MAIL_SMTP_TIMEOUT=15s
# Optional for local self-signed SMTP capture only:
# MAIL_SMTP_INSECURE_SKIP_VERIFY=true
OTEL_TRACES_EXPORTER=none
OTEL_METRICS_EXPORTER=none
```
## Auth Delivery REST
Request:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/internal/login-code-deliveries \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: challenge-123' \
-d '{
"email": "pilot@example.com",
"code": "123456",
"locale": "fr-FR"
}'
```
Success response:
```json
{
"outcome": "sent"
}
```
Suppressed response:
```json
{
"outcome": "suppressed"
}
```
## Async Generic Command Examples
Rendered payload:
```bash
redis-cli XADD mail:delivery_commands '*' \
delivery_id mail-123 \
source notification \
payload_mode rendered \
idempotency_key notification:mail-123 \
request_id req-123 \
trace_id trace-123 \
payload_json '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"subject":"Turn ready","text_body":"Turn 54 is ready.","html_body":"<p>Turn <strong>54</strong> is ready.</p>","attachments":[]}'
```
Template payload:
```bash
redis-cli XADD mail:delivery_commands '*' \
delivery_id mail-124 \
source notification \
payload_mode template \
idempotency_key notification:mail-124 \
request_id req-124 \
trace_id trace-124 \
payload_json '{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn_ready","locale":"fr-FR","variables":{"turn_number":54},"attachments":[]}'
```
## Operator API Examples
List deliveries:
```bash
curl 'http://127.0.0.1:8080/api/v1/internal/deliveries?source=authsession&status=sent&limit=10'
```
Get one delivery:
```bash
curl http://127.0.0.1:8080/api/v1/internal/deliveries/delivery-123
```
List attempts:
```bash
curl http://127.0.0.1:8080/api/v1/internal/deliveries/delivery-123/attempts
```
Resend one terminal delivery:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/internal/deliveries/delivery-123/resend
```
Example resend response:
```json
{
"delivery_id": "delivery-456"
}
```
+100
View File
@@ -0,0 +1,100 @@
# Main Flows
## Auth / Session -> Mail
```mermaid
sequenceDiagram
participant Auth as Auth / Session Service
participant Mail as Mail Service
participant Redis
participant Scheduler
participant SMTP as Provider
Auth->>Mail: POST /api/v1/internal/login-code-deliveries + Idempotency-Key
Mail->>Mail: validate request and idempotency scope
alt MAIL_SMTP_MODE = stub
Mail->>Redis: persist delivery as suppressed
Mail-->>Auth: 200 {outcome=suppressed}
else MAIL_SMTP_MODE = smtp
Mail->>Redis: persist delivery as queued + attempt #1 scheduled
Mail-->>Auth: 200 {outcome=sent}
Scheduler->>Redis: claim due attempt
Scheduler->>SMTP: send rendered auth mail
SMTP-->>Scheduler: accepted or classified failure
Scheduler->>Redis: commit sent / retry / failed / dead_letter
end
```
`sent` on this boundary means durable intake into the mail-delivery pipeline.
It does not mean SMTP completion.
## Notification -> Mail
```mermaid
sequenceDiagram
participant Notify as Notification Service
participant Stream as Redis Stream mail:delivery_commands
participant Consumer as Command consumer
participant Mail as Mail Service
participant Redis
Notify->>Stream: XADD generic command
Consumer->>Stream: XREAD from last stored offset
Consumer->>Mail: decode and validate command
alt malformed or conflicting command
Mail->>Redis: record malformed command entry
Consumer->>Redis: save stream offset
else valid command
Mail->>Redis: persist delivery + first attempt + optional payload bundle
Consumer->>Redis: save stream offset
end
```
## Retry and Dead Letter
```mermaid
sequenceDiagram
participant Scheduler
participant Redis
participant Worker as Attempt worker
participant SMTP as Provider
Scheduler->>Redis: find next due delivery
Scheduler->>Redis: load work item
alt template delivery not yet rendered
Scheduler->>Redis: render and store materialized content
end
Scheduler->>Redis: claim scheduled attempt
Scheduler->>Worker: enqueue claimed work
Worker->>SMTP: send materialized message
SMTP-->>Worker: accepted / suppressed / transient_failure / permanent_failure
alt accepted
Worker->>Redis: commit sent + provider_accepted
else suppressed
Worker->>Redis: commit suppressed + provider_rejected
else transient failure before retry budget ends
Worker->>Redis: commit transport_failed|timed_out + next scheduled attempt
else retry budget exhausted
Worker->>Redis: commit dead_letter + dead-letter entry
else permanent failure
Worker->>Redis: commit failed + provider_rejected
end
```
## Operator Resend
```mermaid
sequenceDiagram
participant Ops as Trusted operator
participant Mail as Mail Service
participant Redis
Ops->>Mail: POST /api/v1/internal/deliveries/{delivery_id}/resend
Mail->>Redis: load original delivery and optional payload bundle
Mail->>Mail: verify original status is terminal
Mail->>Redis: create clone delivery with source=operator_resend
Mail-->>Ops: 200 {delivery_id=<clone>}
```
Resend always creates a new delivery and never mutates the original delivery or
its attempt history.
+177
View File
@@ -0,0 +1,177 @@
# Operator Runbook
This runbook covers the checks that matter most during startup, steady-state
verification, shutdown, and common `Mail Service` incidents.
## Startup Checks
Before starting the process, confirm:
- `MAIL_REDIS_ADDR` points to the Redis deployment that stores deliveries,
attempts, idempotency reservations, malformed commands, and stream offsets
- the configured Redis ACL, DB, TLS, and timeout settings match the target
environment
- `MAIL_TEMPLATE_DIR` points to the intended immutable template catalog
- if `MAIL_SMTP_MODE=smtp`, the SMTP address, sender identity, and optional
credentials are configured together
- the OpenTelemetry exporter settings point at the intended collector when
traces or metrics are expected outside the process
At startup the process performs bounded `PING` checks for both Redis clients
used by the runtime and parses the full template catalog.
Startup fails fast if those checks fail or if the template catalog cannot be
loaded.
Known startup caveats:
- there is no `/healthz`, `/readyz`, or `/metrics` route
- traces and metrics are exported only through the configured OpenTelemetry
exporters
- template changes are not hot-reloaded; restart is required after template
edits
## Steady-State Verification
Practical readiness verification is:
1. confirm the process emitted startup logs for the internal HTTP listener,
command consumer, scheduler, and worker pool
2. open a TCP connection to `MAIL_INTERNAL_HTTP_ADDR`
3. issue one trusted smoke request such as
`GET /api/v1/internal/deliveries/does-not-exist`
4. verify Redis connectivity and OpenTelemetry exporter health out of band
Expected steady-state signals:
- `mail.attempt_schedule.depth` remains bounded
- `mail.attempt_schedule.oldest_age_ms` stays near the active retry ladder
- `mail.delivery.dead_letters` changes rarely
- `mail.stream_commands.malformed` changes only on bad upstream commands
- internal HTTP logs include `otel_trace_id` and `otel_span_id`
## Shutdown
The process handles `SIGINT` and `SIGTERM`.
Shutdown behavior:
- coordinated shutdown is bounded by `MAIL_SHUTDOWN_TIMEOUT`
- the internal HTTP listener is stopped before process resources are closed
- Redis clients are closed after the app stops
- OpenTelemetry providers are flushed during runtime cleanup
During a planned restart:
1. send `SIGTERM`
2. wait for listener and worker shutdown logs
3. restart the process with the same Redis and template configuration
4. repeat the steady-state verification steps
## Incident Triage
### Attempt Schedule Backlog Grows
Symptoms:
- `mail.attempt_schedule.depth` rises steadily
- `mail.attempt_schedule.oldest_age_ms` increases instead of oscillating
- queued deliveries remain in `queued` or `rendered` longer than expected
Checks:
1. confirm the scheduler is still logging regular activity
2. confirm Redis connectivity and latency for attempt-schedule keys
3. confirm attempt workers are running and not blocked on SMTP
4. inspect `mail.provider.send.duration_ms` for elevated latency
5. verify `MAIL_ATTEMPT_WORKER_CONCURRENCY` is appropriate for the workload
### Dead-Letter Spikes
Symptoms:
- `mail.delivery.dead_letters` increases rapidly
- operator reads show repeated `dead_letter` deliveries with recent
`transport_failed` or `timed_out` attempts
Checks:
1. inspect recent provider summaries on dead-lettered deliveries
2. confirm SMTP reachability from the Mail Service process
3. compare the spike against `mail.provider.send.duration_ms` and timeout logs
4. verify the remote SMTP server is accepting `STARTTLS` and mail submission
Expected behavior:
- dead letters appear only after the fixed retry ladder is exhausted
- each dead-lettered delivery has a matching dead-letter entry
### Repeated `suppressed` Outcomes
Symptoms:
- `mail.delivery.suppressed` rises unexpectedly
- auth or generic deliveries end as `suppressed`
Checks:
1. determine whether the source is `authsession` or `notification`
2. for auth deliveries, confirm the service is not intentionally running in
`MAIL_SMTP_MODE=stub`
3. inspect provider summaries for policy-driven suppression markers
4. confirm the upstream business workflow still expects those deliveries to be
skipped
Expected behavior:
- auth suppression is valid in stub mode and still counts as successful intake
- provider-side suppression is recorded as
`mail_attempt.status=provider_rejected` together with
`mail_delivery.status=suppressed`
### SMTP Authentication Failures
Symptoms:
- provider summaries indicate auth or login failures
- delivery attempts shift toward `failed` or repeated retryable failures,
depending on provider classification
Checks:
1. verify `MAIL_SMTP_USERNAME` and `MAIL_SMTP_PASSWORD` are both configured
2. verify the credential pair is valid for the target SMTP server
3. verify the sender identity matches the allowed submission account
4. confirm the server advertises the expected authentication mechanisms
### SMTP Timeouts
Symptoms:
- `mail.attempt.outcomes{status="timed_out"}` increases
- `mail.provider.send.duration_ms` shifts upward
- logs show retry scheduling or dead-letter transitions after timeout paths
Checks:
1. confirm network reachability to `MAIL_SMTP_ADDR`
2. compare observed send duration with `MAIL_SMTP_TIMEOUT`
3. verify the SMTP server is not stalling during `STARTTLS`, auth, or `DATA`
4. confirm the process is not CPU-starved or blocked on Redis
### Malformed Stream Commands
Symptoms:
- `mail.stream_commands.malformed` increases
- logs contain `stream command rejected`
Checks:
1. inspect `failure_code`, `delivery_id`, `source`, and `stream_entry_id`
2. confirm the upstream command payload still matches
[`../api/delivery-commands-asyncapi.yaml`](../api/delivery-commands-asyncapi.yaml)
3. confirm the producer still sends canonical `payload_mode`, locale, and
idempotency fields
4. review stored malformed-command records through the operator tooling or
direct Redis inspection
+187
View File
@@ -0,0 +1,187 @@
# Runtime and Components
The diagram below focuses on the deployed `galaxy/mail` process and its runtime
dependencies.
```mermaid
flowchart LR
subgraph Callers
Auth["Auth / Session Service"]
Notify["Notification Service"]
Ops["Trusted operators"]
end
subgraph Mail["Mail Service process"]
InternalHTTP["Trusted internal HTTP listener\n/api/v1/internal/*"]
Consumer["Redis Stream command consumer"]
Scheduler["Attempt scheduler"]
Workers["Attempt worker pool"]
Cleanup["Index cleanup worker"]
Services["Application services"]
Templates["Immutable template catalog"]
Telemetry["Logs, traces, metrics"]
end
Redis["Redis\nstate + streams + indexes"]
Provider["SMTP or stub provider"]
Auth --> InternalHTTP
Ops --> InternalHTTP
Notify --> Redis
InternalHTTP --> Services
Consumer --> Services
Scheduler --> Services
Workers --> Services
Cleanup --> Services
Services --> Templates
Services --> Redis
Services --> Provider
InternalHTTP --> Telemetry
Consumer --> Telemetry
Scheduler --> Telemetry
Workers --> Telemetry
```
## Listener
`mail` exposes exactly one HTTP listener:
| Listener | Default addr | Purpose |
| --- | --- | --- |
| Internal HTTP | `:8080` | Trusted intake, operator reads, and resend |
Shared listener defaults:
- read-header timeout: `2s`
- read timeout: `10s`
- idle timeout: `1m`
Intentional omissions:
- no public listener
- no `/healthz`
- no `/readyz`
- no `/metrics`
## Startup Wiring
`cmd/mail` loads config, constructs logging, and builds the runtime through
`internal/app.NewRuntime`.
The runtime wires:
- Redis clients for state access and blocking stream consumption
- filesystem-backed template catalog
- provider adapter selected by `MAIL_SMTP_MODE`
- acceptance, render, execution, operator-read, and resend services
- internal HTTP server
- command consumer
- scheduler
- attempt worker pool
- cleanup worker
Before startup completes, the process performs bounded `PING` checks for both
Redis clients and validates the template catalog. Startup fails fast on invalid
configuration or unavailable Redis.
## Background Components
### Command consumer
- reads one plain `XREAD` stream
- starts from stored offset or `0-0`
- advances offset only after durable command acceptance or durable malformed
command recording
### Scheduler
- polls due work every `250ms`
- recovers stale claims every `30s`
- derives recovery deadline from `MAIL_SMTP_TIMEOUT + 30s`
### Attempt worker pool
- processes only already claimed work items
- concurrency is controlled by `MAIL_ATTEMPT_WORKER_CONCURRENCY`
### Cleanup worker
- removes stale delivery-index members after primary delivery expiry
- does not clean `mail:attempt_schedule`
- does not clean malformed-command index entries
## Configuration Groups
Required for all starts:
- `MAIL_REDIS_ADDR`
Core process config:
- `MAIL_SHUTDOWN_TIMEOUT`
- `MAIL_LOG_LEVEL`
Internal HTTP config:
- `MAIL_INTERNAL_HTTP_ADDR`
- `MAIL_INTERNAL_HTTP_READ_HEADER_TIMEOUT`
- `MAIL_INTERNAL_HTTP_READ_TIMEOUT`
- `MAIL_INTERNAL_HTTP_IDLE_TIMEOUT`
Redis connectivity:
- `MAIL_REDIS_USERNAME`
- `MAIL_REDIS_PASSWORD`
- `MAIL_REDIS_DB`
- `MAIL_REDIS_TLS_ENABLED`
- `MAIL_REDIS_OPERATION_TIMEOUT`
- `MAIL_REDIS_COMMAND_STREAM`
- `MAIL_REDIS_ATTEMPT_SCHEDULE_KEY`
- `MAIL_REDIS_DEAD_LETTER_PREFIX`
SMTP provider:
- `MAIL_SMTP_MODE`
- `MAIL_SMTP_ADDR`
- `MAIL_SMTP_USERNAME`
- `MAIL_SMTP_PASSWORD`
- `MAIL_SMTP_FROM_EMAIL`
- `MAIL_SMTP_FROM_NAME`
- `MAIL_SMTP_TIMEOUT`
- `MAIL_SMTP_INSECURE_SKIP_VERIFY`
Templates and workers:
- `MAIL_TEMPLATE_DIR`
- `MAIL_ATTEMPT_WORKER_CONCURRENCY`
- `MAIL_STREAM_BLOCK_TIMEOUT`
- `MAIL_OPERATOR_REQUEST_TIMEOUT`
- `MAIL_IDEMPOTENCY_TTL`
- `MAIL_DELIVERY_TTL`
- `MAIL_ATTEMPT_TTL`
Telemetry:
- `OTEL_SERVICE_NAME`
- `OTEL_TRACES_EXPORTER`
- `OTEL_METRICS_EXPORTER`
- `OTEL_EXPORTER_OTLP_PROTOCOL`
- `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`
- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL`
- `MAIL_OTEL_STDOUT_TRACES_ENABLED`
- `MAIL_OTEL_STDOUT_METRICS_ENABLED`
## Runtime Notes
- `MAIL_REDIS_COMMAND_STREAM` is the only Redis key override that currently
changes runtime behavior
- `MAIL_SMTP_INSECURE_SKIP_VERIFY` is a local-development escape hatch for
self-signed SMTP capture only and should remain disabled in production
- attempt-schedule and dead-letter key overrides are parsed but not yet wired
into Redis adapters
- retention overrides are parsed but storage still uses the fixed `7d`, `30d`,
and `90d` values
- template catalog parsing is eager and immutable
- auth deliveries in `MAIL_SMTP_MODE=stub` surface as `suppressed`
- auth deliveries in `MAIL_SMTP_MODE=smtp` surface as `queued` and later move
through normal attempt execution
+100
View File
@@ -0,0 +1,100 @@
module galaxy/mail
go 1.26.1
require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/getkin/kin-openapi v0.135.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/extra/redisotel/v9 v9.18.0
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
github.com/wneessen/go-mail v0.7.2
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/text v0.36.0
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+225
View File
@@ -0,0 +1,225 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 h1:QY4nmPHLFAJjtT5O4OMUEOxP8WVaRNOFpcbmxT2NLZU=
github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0/go.mod h1:WH8cY/0fT41Bsf341qzo8v4nx0GCE8FykAA23IVbVmo=
github.com/redis/go-redis/extra/redisotel/v9 v9.18.0 h1:2dKdoEYBJ0CZCLPiCdvvc7luz3DPwY6hKdzjL6m1eHE=
github.com/redis/go-redis/extra/redisotel/v9 v9.18.0/go.mod h1:WzkrVG9ro9BwCQD0eJOWn6AGL4Z1CleGflM45w1hu10=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+23
View File
@@ -0,0 +1,23 @@
// Package id provides internal identifier generators used by Mail Service.
package id
import (
"fmt"
"galaxy/mail/internal/domain/common"
"github.com/google/uuid"
)
// Generator builds UUID-backed internal delivery identifiers.
type Generator struct{}
// NewDeliveryID returns one new UUID v4 delivery identifier.
func (Generator) NewDeliveryID() (common.DeliveryID, error) {
value, err := uuid.NewRandom()
if err != nil {
return "", fmt.Errorf("new delivery id: %w", err)
}
return common.DeliveryID(value.String()), nil
}
@@ -0,0 +1,501 @@
package redisstate
import (
"context"
"errors"
"fmt"
"time"
"galaxy/mail/internal/domain/attempt"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/service/acceptgenericdelivery"
"github.com/redis/go-redis/v9"
)
// AtomicWriter performs the minimal multi-key Redis mutations that later Mail
// Service acceptance flows will need.
type AtomicWriter struct {
client *redis.Client
keyspace Keyspace
}
// CreateAcceptanceInput describes the frozen write set required to durably
// accept one delivery into Redis-backed state.
type CreateAcceptanceInput struct {
// Delivery stores the accepted delivery record.
Delivery deliverydomain.Delivery
// FirstAttempt stores the optional first scheduled attempt record.
FirstAttempt *attempt.Attempt
// DeliveryPayload stores the optional raw attachment payload bundle.
DeliveryPayload *acceptgenericdelivery.DeliveryPayload
// Idempotency stores the optional idempotency reservation to create
// together with the delivery. Resend clone creation can omit it.
Idempotency *idempotency.Record
}
// MarkRenderedInput describes the durable mutation applied after successful
// template materialization.
type MarkRenderedInput struct {
// Delivery stores the rendered delivery record.
Delivery deliverydomain.Delivery
}
// Validate reports whether input contains one rendered template delivery.
func (input MarkRenderedInput) Validate() error {
if err := input.Delivery.Validate(); err != nil {
return fmt.Errorf("delivery: %w", err)
}
if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate {
return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate)
}
if input.Delivery.Status != deliverydomain.StatusRendered {
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusRendered)
}
return nil
}
// MarkRenderFailedInput describes the durable mutation applied after one
// classified render failure.
type MarkRenderFailedInput struct {
// Delivery stores the failed delivery record.
Delivery deliverydomain.Delivery
// Attempt stores the terminal render-failed attempt.
Attempt attempt.Attempt
}
// Validate reports whether input contains one failed delivery and its
// terminal render-failed attempt.
func (input MarkRenderFailedInput) Validate() error {
if err := input.Delivery.Validate(); err != nil {
return fmt.Errorf("delivery: %w", err)
}
if err := input.Attempt.Validate(); err != nil {
return fmt.Errorf("attempt: %w", err)
}
if input.Delivery.PayloadMode != deliverydomain.PayloadModeTemplate {
return fmt.Errorf("delivery payload mode must be %q", deliverydomain.PayloadModeTemplate)
}
if input.Delivery.Status != deliverydomain.StatusFailed {
return fmt.Errorf("delivery status must be %q", deliverydomain.StatusFailed)
}
if input.Attempt.Status != attempt.StatusRenderFailed {
return fmt.Errorf("attempt status must be %q", attempt.StatusRenderFailed)
}
if input.Attempt.DeliveryID != input.Delivery.DeliveryID {
return errors.New("attempt delivery id must match delivery id")
}
if input.Delivery.LastAttemptStatus != attempt.StatusRenderFailed {
return fmt.Errorf("delivery last attempt status must be %q", attempt.StatusRenderFailed)
}
return nil
}
// Validate reports whether CreateAcceptanceInput is internally consistent.
func (input CreateAcceptanceInput) Validate() error {
if err := input.Delivery.Validate(); err != nil {
return fmt.Errorf("delivery: %w", err)
}
switch {
case input.FirstAttempt == nil:
if input.Delivery.Status != deliverydomain.StatusSuppressed {
return errors.New("first attempt must not be nil unless delivery status is suppressed")
}
case input.Delivery.Status == deliverydomain.StatusSuppressed:
return errors.New("suppressed delivery must not create first attempt")
default:
if err := input.FirstAttempt.Validate(); err != nil {
return fmt.Errorf("first attempt: %w", err)
}
if input.FirstAttempt.DeliveryID != input.Delivery.DeliveryID {
return errors.New("first attempt delivery id must match delivery id")
}
if input.FirstAttempt.Status != attempt.StatusScheduled {
return fmt.Errorf("first attempt status must be %q", attempt.StatusScheduled)
}
}
if input.DeliveryPayload != nil {
if err := input.DeliveryPayload.Validate(); err != nil {
return fmt.Errorf("delivery payload: %w", err)
}
if input.DeliveryPayload.DeliveryID != input.Delivery.DeliveryID {
return errors.New("delivery payload delivery id must match delivery id")
}
}
if input.Idempotency == nil {
return nil
}
if err := input.Idempotency.Validate(); err != nil {
return fmt.Errorf("idempotency: %w", err)
}
if input.Idempotency.DeliveryID != input.Delivery.DeliveryID {
return errors.New("idempotency delivery id must match delivery id")
}
if input.Idempotency.Source != input.Delivery.Source {
return errors.New("idempotency source must match delivery source")
}
if input.Idempotency.IdempotencyKey != input.Delivery.IdempotencyKey {
return errors.New("idempotency key must match delivery idempotency key")
}
if input.Idempotency.ExpiresAt.Sub(input.Idempotency.CreatedAt) != IdempotencyTTL {
return fmt.Errorf("idempotency retention must equal %s", IdempotencyTTL)
}
return nil
}
// NewAtomicWriter constructs a low-level Redis mutation helper.
func NewAtomicWriter(client *redis.Client) (*AtomicWriter, error) {
if client == nil {
return nil, errors.New("new redis atomic writer: nil client")
}
return &AtomicWriter{
client: client,
keyspace: Keyspace{},
}, nil
}
// CreateAcceptance stores one delivery, the optional first scheduled attempt,
// the optional first schedule entry, the delivery-level secondary indexes, and
// an optional idempotency record in one optimistic Redis transaction.
func (writer *AtomicWriter) CreateAcceptance(ctx context.Context, input CreateAcceptanceInput) error {
if writer == nil || writer.client == nil {
return errors.New("create acceptance in redis: nil writer")
}
if ctx == nil {
return errors.New("create acceptance in redis: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
deliveryPayload, err := MarshalDelivery(input.Delivery)
if err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
var (
attemptKey string
attemptPayload []byte
deliveryPayloadKey string
deliveryPayloadBytes []byte
scheduleScore float64
idempotencyKey string
idempotencyPayload []byte
idempotencyTTL time.Duration
)
if input.FirstAttempt != nil {
attemptPayload, err = MarshalAttempt(*input.FirstAttempt)
if err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
attemptKey = writer.keyspace.Attempt(input.FirstAttempt.DeliveryID, input.FirstAttempt.AttemptNo)
scheduleScore = ScheduledForScore(input.FirstAttempt.ScheduledFor)
}
if input.DeliveryPayload != nil {
deliveryPayloadBytes, err = MarshalDeliveryPayload(*input.DeliveryPayload)
if err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
deliveryPayloadKey = writer.keyspace.DeliveryPayload(input.DeliveryPayload.DeliveryID)
}
if input.Idempotency != nil {
idempotencyPayload, err = MarshalIdempotency(*input.Idempotency)
if err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
idempotencyTTL, err = ttlUntil(input.Idempotency.ExpiresAt)
if err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
idempotencyKey = writer.keyspace.Idempotency(input.Idempotency.Source, input.Idempotency.IdempotencyKey)
}
deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID)
watchKeys := []string{deliveryKey}
if attemptKey != "" {
watchKeys = append(watchKeys, attemptKey)
}
if deliveryPayloadKey != "" {
watchKeys = append(watchKeys, deliveryPayloadKey)
}
if idempotencyKey != "" {
watchKeys = append(watchKeys, idempotencyKey)
}
indexKeys := writer.keyspace.DeliveryIndexKeys(input.Delivery)
createdAtScore := CreatedAtScore(input.Delivery.CreatedAt)
deliveryMember := input.Delivery.DeliveryID.String()
watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error {
for _, key := range watchKeys {
if err := ensureKeyAbsent(ctx, tx, key); err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
}
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, deliveryKey, deliveryPayload, DeliveryTTL)
if attemptKey != "" {
pipe.Set(ctx, attemptKey, attemptPayload, AttemptTTL)
}
if deliveryPayloadKey != "" {
pipe.Set(ctx, deliveryPayloadKey, deliveryPayloadBytes, DeliveryTTL)
}
if idempotencyKey != "" {
pipe.Set(ctx, idempotencyKey, idempotencyPayload, idempotencyTTL)
}
if attemptKey != "" {
pipe.ZAdd(ctx, writer.keyspace.AttemptSchedule(), redis.Z{
Score: scheduleScore,
Member: deliveryMember,
})
}
for _, indexKey := range indexKeys {
pipe.ZAdd(ctx, indexKey, redis.Z{
Score: createdAtScore,
Member: deliveryMember,
})
}
return nil
})
if err != nil {
return fmt.Errorf("create acceptance in redis: %w", err)
}
return nil
}, watchKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("create acceptance in redis: %w", ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// MarkRendered stores the successful materialization result for one queued
// template delivery and updates the delivery-status secondary index
// atomically.
func (writer *AtomicWriter) MarkRendered(ctx context.Context, input MarkRenderedInput) error {
if writer == nil || writer.client == nil {
return errors.New("mark rendered in redis: nil writer")
}
if ctx == nil {
return errors.New("mark rendered in redis: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("mark rendered in redis: %w", err)
}
deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID)
deliveryPayload, err := MarshalDelivery(input.Delivery)
if err != nil {
return fmt.Errorf("mark rendered in redis: %w", err)
}
watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error {
currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
if err != nil {
return fmt.Errorf("mark rendered in redis: %w", err)
}
if currentDelivery.Status != deliverydomain.StatusQueued {
return fmt.Errorf("mark rendered in redis: %w", ErrConflict)
}
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
if err != nil {
return fmt.Errorf("mark rendered in redis: %w", err)
}
createdAtScore := CreatedAtScore(currentDelivery.CreatedAt)
deliveryMember := input.Delivery.DeliveryID.String()
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
pipe.ZRem(ctx, writer.keyspace.StatusIndex(currentDelivery.Status), deliveryMember)
pipe.ZAdd(ctx, writer.keyspace.StatusIndex(input.Delivery.Status), redis.Z{
Score: createdAtScore,
Member: deliveryMember,
})
return nil
})
if err != nil {
return fmt.Errorf("mark rendered in redis: %w", err)
}
return nil
}, deliveryKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("mark rendered in redis: %w", ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// MarkRenderFailed stores one terminal render-failed attempt together with
// the owning failed delivery and updates the delivery-status secondary index
// atomically.
func (writer *AtomicWriter) MarkRenderFailed(ctx context.Context, input MarkRenderFailedInput) error {
if writer == nil || writer.client == nil {
return errors.New("mark render failed in redis: nil writer")
}
if ctx == nil {
return errors.New("mark render failed in redis: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
deliveryKey := writer.keyspace.Delivery(input.Delivery.DeliveryID)
attemptKey := writer.keyspace.Attempt(input.Attempt.DeliveryID, input.Attempt.AttemptNo)
deliveryPayload, err := MarshalDelivery(input.Delivery)
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
attemptPayload, err := MarshalAttempt(input.Attempt)
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
watchErr := writer.client.Watch(ctx, func(tx *redis.Tx) error {
currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
currentAttempt, err := loadAttemptFromTx(ctx, tx, attemptKey)
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
if currentDelivery.Status != deliverydomain.StatusQueued {
return fmt.Errorf("mark render failed in redis: %w", ErrConflict)
}
if currentAttempt.Status != attempt.StatusScheduled {
return fmt.Errorf("mark render failed in redis: %w", ErrConflict)
}
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
attemptTTL, err := ttlForExistingKey(ctx, tx, attemptKey, AttemptTTL)
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
createdAtScore := CreatedAtScore(currentDelivery.CreatedAt)
deliveryMember := input.Delivery.DeliveryID.String()
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
pipe.Set(ctx, attemptKey, attemptPayload, attemptTTL)
pipe.ZRem(ctx, writer.keyspace.StatusIndex(currentDelivery.Status), deliveryMember)
pipe.ZAdd(ctx, writer.keyspace.StatusIndex(input.Delivery.Status), redis.Z{
Score: createdAtScore,
Member: deliveryMember,
})
pipe.ZRem(ctx, writer.keyspace.AttemptSchedule(), deliveryMember)
return nil
})
if err != nil {
return fmt.Errorf("mark render failed in redis: %w", err)
}
return nil
}, deliveryKey, attemptKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("mark render failed in redis: %w", ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func ensureKeyAbsent(ctx context.Context, tx *redis.Tx, key string) error {
exists, err := tx.Exists(ctx, key).Result()
if err != nil {
return err
}
if exists > 0 {
return ErrConflict
}
return nil
}
func loadDeliveryFromTx(ctx context.Context, tx *redis.Tx, key string) (deliverydomain.Delivery, error) {
payload, err := tx.Get(ctx, key).Bytes()
switch {
case errors.Is(err, redis.Nil):
return deliverydomain.Delivery{}, ErrConflict
case err != nil:
return deliverydomain.Delivery{}, err
}
record, err := UnmarshalDelivery(payload)
if err != nil {
return deliverydomain.Delivery{}, err
}
return record, nil
}
func loadAttemptFromTx(ctx context.Context, tx *redis.Tx, key string) (attempt.Attempt, error) {
payload, err := tx.Get(ctx, key).Bytes()
switch {
case errors.Is(err, redis.Nil):
return attempt.Attempt{}, ErrConflict
case err != nil:
return attempt.Attempt{}, err
}
record, err := UnmarshalAttempt(payload)
if err != nil {
return attempt.Attempt{}, err
}
return record, nil
}
func ttlForExistingKey(ctx context.Context, tx *redis.Tx, key string, fallback time.Duration) (time.Duration, error) {
ttl, err := tx.PTTL(ctx, key).Result()
if err != nil {
return 0, err
}
if ttl <= 0 {
return fallback, nil
}
return ttl, nil
}
func ttlUntil(expiresAt time.Time) (time.Duration, error) {
ttl := time.Until(expiresAt)
if ttl <= 0 {
return 0, errors.New("idempotency expires at must be in the future")
}
return ttl, nil
}
@@ -0,0 +1,429 @@
package redisstate
import (
"context"
"errors"
"sync"
"testing"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestAtomicWriterCreateAcceptanceStoresStateWithoutIdempotencyRecord(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.SentAt = nil
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
require.NoError(t, record.Validate())
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
input := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(firstAttempt),
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
}
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
require.NoError(t, err)
require.Equal(t, record, decodedDelivery)
storedAttempt, err := client.Get(context.Background(), Keyspace{}.Attempt(record.DeliveryID, firstAttempt.AttemptNo)).Bytes()
require.NoError(t, err)
decodedAttempt, err := UnmarshalAttempt(storedAttempt)
require.NoError(t, err)
require.Equal(t, firstAttempt, decodedAttempt)
storedDeliveryPayload, err := client.Get(context.Background(), Keyspace{}.DeliveryPayload(record.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDeliveryPayload, err := UnmarshalDeliveryPayload(storedDeliveryPayload)
require.NoError(t, err)
require.Equal(t, *input.DeliveryPayload, decodedDeliveryPayload)
scheduledDeliveries, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{record.DeliveryID.String()}, scheduledDeliveries)
recipientMembers, err := client.ZRange(context.Background(), Keyspace{}.RecipientIndex(record.Envelope.To[0]), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{record.DeliveryID.String()}, recipientMembers)
idempotencyMembers, err := client.ZRange(context.Background(), Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{record.DeliveryID.String()}, idempotencyMembers)
}
func TestAtomicWriterCreateAcceptanceDetectsDuplicateIdempotencyRace(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.SentAt = nil
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
require.NoError(t, record.Validate())
input := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}
const contenders = 8
var (
wg sync.WaitGroup
successes int
conflicts int
mu sync.Mutex
)
for range contenders {
wg.Add(1)
go func() {
defer wg.Done()
err := writer.CreateAcceptance(context.Background(), input)
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
successes++
case errors.Is(err, ErrConflict):
conflicts++
default:
t.Errorf("unexpected error: %v", err)
}
}()
}
wg.Wait()
require.Equal(t, 1, successes)
require.Equal(t, contenders-1, conflicts)
require.True(t, server.Exists(Keyspace{}.Delivery(record.DeliveryID)))
require.NotNil(t, input.FirstAttempt)
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
require.True(t, server.Exists(Keyspace{}.DeliveryPayload(record.DeliveryID)))
require.True(t, server.Exists(Keyspace{}.Idempotency(record.Source, record.IdempotencyKey)))
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, scheduleCard)
createdAtCard, err := client.ZCard(context.Background(), Keyspace{}.CreatedAtIndex()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, createdAtCard)
idempotencyCard, err := client.ZCard(context.Background(), Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey)).Result()
require.NoError(t, err)
require.EqualValues(t, 1, idempotencyCard)
}
func TestCreateAcceptanceInputValidateRejectsMismatchedDeliveryPayload(t *testing.T) {
t.Parallel()
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.SentAt = nil
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
require.NoError(t, record.Validate())
payload := validDeliveryPayload(t, common.DeliveryID("delivery-other"))
input := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
DeliveryPayload: &payload,
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}
err := input.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "delivery payload delivery id must match delivery id")
}
func TestCreateAcceptanceInputValidateRejectsMismatchedIdempotency(t *testing.T) {
t.Parallel()
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.SentAt = nil
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
require.NoError(t, record.Validate())
input := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
Idempotency: ptr(validIdempotencyRecord(t, deliverydomain.SourceAuthSession, record.DeliveryID, record.IdempotencyKey)),
}
err := input.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "idempotency source must match delivery source")
}
func TestCreateAcceptanceInputValidateRejectsUnexpectedIdempotencyRetention(t *testing.T) {
t.Parallel()
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.SentAt = nil
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
require.NoError(t, record.Validate())
idempotencyRecord := validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)
idempotencyRecord.ExpiresAt = idempotencyRecord.CreatedAt.Add(time.Hour)
input := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
Idempotency: ptr(idempotencyRecord),
}
err := input.Validate()
require.Error(t, err)
require.ErrorContains(t, err, "idempotency retention must equal")
}
func TestAtomicWriterCreateAcceptanceStoresSuppressedStateWithoutAttempt(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceAuthSession
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusSuppressed
record.AttemptCount = 0
record.LastAttemptStatus = ""
record.ProviderSummary = ""
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
record.SentAt = nil
record.SuppressedAt = ptr(record.UpdatedAt)
require.NoError(t, record.Validate())
input := CreateAcceptanceInput{
Delivery: record,
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
require.NoError(t, err)
require.Equal(t, record, decodedDelivery)
require.False(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, 1)))
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
require.NoError(t, err)
require.Zero(t, scheduleCard)
}
func TestAtomicWriterMarkRenderedUpdatesDeliveryAndStatusIndex(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
record := validQueuedTemplateDelivery(t)
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
createInput := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(firstAttempt),
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}
require.NoError(t, writer.CreateAcceptance(context.Background(), createInput))
rendered := record
rendered.Status = deliverydomain.StatusRendered
rendered.Content = deliverydomain.Content{
Subject: "Turn 54",
TextBody: "Hello Pilot",
HTMLBody: "<p>Hello Pilot</p>",
}
rendered.LocaleFallbackUsed = true
rendered.UpdatedAt = rendered.CreatedAt.Add(time.Minute)
require.NoError(t, rendered.Validate())
require.NoError(t, writer.MarkRendered(context.Background(), MarkRenderedInput{
Delivery: rendered,
}))
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
require.NoError(t, err)
require.Equal(t, rendered, decodedDelivery)
queuedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusQueued), 0, -1).Result()
require.NoError(t, err)
require.Empty(t, queuedMembers)
renderedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusRendered), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{record.DeliveryID.String()}, renderedMembers)
}
func TestAtomicWriterMarkRenderFailedUpdatesDeliveryAttemptAndStatusIndex(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
record := validQueuedTemplateDelivery(t)
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
createInput := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(firstAttempt),
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}
require.NoError(t, writer.CreateAcceptance(context.Background(), createInput))
failed := record
failed.Status = deliverydomain.StatusFailed
failed.LastAttemptStatus = attempt.StatusRenderFailed
failed.ProviderSummary = "missing required variables: player.name"
failed.UpdatedAt = failed.CreatedAt.Add(time.Minute)
failed.FailedAt = ptr(failed.UpdatedAt)
require.NoError(t, failed.Validate())
renderFailedAttempt := validRenderFailedAttempt(t, record.DeliveryID)
require.NoError(t, writer.MarkRenderFailed(context.Background(), MarkRenderFailedInput{
Delivery: failed,
Attempt: renderFailedAttempt,
}))
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
require.NoError(t, err)
require.Equal(t, failed, decodedDelivery)
storedAttempt, err := client.Get(context.Background(), Keyspace{}.Attempt(record.DeliveryID, 1)).Bytes()
require.NoError(t, err)
decodedAttempt, err := UnmarshalAttempt(storedAttempt)
require.NoError(t, err)
require.Equal(t, renderFailedAttempt, decodedAttempt)
queuedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusQueued), 0, -1).Result()
require.NoError(t, err)
require.Empty(t, queuedMembers)
failedMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusFailed), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{record.DeliveryID.String()}, failedMembers)
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
require.NoError(t, err)
require.Empty(t, scheduledMembers)
}
func TestAtomicWriterMarkRenderedRejectsUnexpectedCurrentState(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
record := validQueuedTemplateDelivery(t)
firstAttempt := validScheduledAttempt(t, record.DeliveryID)
require.NoError(t, writer.CreateAcceptance(context.Background(), CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(firstAttempt),
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}))
failed := record
failed.Status = deliverydomain.StatusFailed
failed.LastAttemptStatus = attempt.StatusRenderFailed
failed.ProviderSummary = "missing required variables: player.name"
failed.UpdatedAt = failed.CreatedAt.Add(time.Minute)
failed.FailedAt = ptr(failed.UpdatedAt)
require.NoError(t, failed.Validate())
require.NoError(t, writer.MarkRenderFailed(context.Background(), MarkRenderFailedInput{
Delivery: failed,
Attempt: validRenderFailedAttempt(t, record.DeliveryID),
}))
rendered := record
rendered.Status = deliverydomain.StatusRendered
rendered.Content = deliverydomain.Content{
Subject: "Turn 54",
TextBody: "Hello Pilot",
}
rendered.UpdatedAt = rendered.CreatedAt.Add(2 * time.Minute)
require.NoError(t, rendered.Validate())
err = writer.MarkRendered(context.Background(), MarkRenderedInput{Delivery: rendered})
require.Error(t, err)
require.ErrorIs(t, err, ErrConflict)
}
func ptr[T any](value T) *T {
return &value
}
var _ = attempt.Attempt{}
@@ -0,0 +1,502 @@
package redisstate
import (
"context"
"errors"
"fmt"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/service/acceptgenericdelivery"
"galaxy/mail/internal/service/executeattempt"
"galaxy/mail/internal/telemetry"
"github.com/redis/go-redis/v9"
)
var errNotClaimable = errors.New("attempt is not claimable")
// AttemptExecutionStore provides the Redis-backed durable storage used by the
// attempt scheduler and attempt execution service.
type AttemptExecutionStore struct {
client *redis.Client
keys Keyspace
}
// NewAttemptExecutionStore constructs one Redis-backed attempt execution
// store.
func NewAttemptExecutionStore(client *redis.Client) (*AttemptExecutionStore, error) {
if client == nil {
return nil, errors.New("new attempt execution store: nil redis client")
}
return &AttemptExecutionStore{
client: client,
keys: Keyspace{},
}, nil
}
// NextDueDeliveryIDs returns up to limit due delivery identifiers ordered by
// the attempt schedule score.
func (store *AttemptExecutionStore) NextDueDeliveryIDs(ctx context.Context, now time.Time, limit int64) ([]common.DeliveryID, error) {
if store == nil || store.client == nil {
return nil, errors.New("next due delivery ids: nil store")
}
if ctx == nil {
return nil, errors.New("next due delivery ids: nil context")
}
if limit <= 0 {
return nil, errors.New("next due delivery ids: non-positive limit")
}
values, err := store.client.ZRangeByScore(ctx, store.keys.AttemptSchedule(), &redis.ZRangeBy{
Min: "-inf",
Max: fmt.Sprintf("%d", now.UTC().UnixMilli()),
Count: limit,
}).Result()
if err != nil {
return nil, fmt.Errorf("next due delivery ids: %w", err)
}
ids := make([]common.DeliveryID, len(values))
for index, value := range values {
ids[index] = common.DeliveryID(value)
}
return ids, nil
}
// ReadAttemptScheduleSnapshot returns the current depth of the durable attempt
// schedule together with its oldest scheduled timestamp when one exists.
func (store *AttemptExecutionStore) ReadAttemptScheduleSnapshot(ctx context.Context) (telemetry.AttemptScheduleSnapshot, error) {
if store == nil || store.client == nil {
return telemetry.AttemptScheduleSnapshot{}, errors.New("read attempt schedule snapshot: nil store")
}
if ctx == nil {
return telemetry.AttemptScheduleSnapshot{}, errors.New("read attempt schedule snapshot: nil context")
}
depth, err := store.client.ZCard(ctx, store.keys.AttemptSchedule()).Result()
if err != nil {
return telemetry.AttemptScheduleSnapshot{}, fmt.Errorf("read attempt schedule snapshot: depth: %w", err)
}
snapshot := telemetry.AttemptScheduleSnapshot{
Depth: depth,
}
if depth == 0 {
return snapshot, nil
}
values, err := store.client.ZRangeWithScores(ctx, store.keys.AttemptSchedule(), 0, 0).Result()
if err != nil {
return telemetry.AttemptScheduleSnapshot{}, fmt.Errorf("read attempt schedule snapshot: oldest scheduled entry: %w", err)
}
if len(values) == 0 {
return snapshot, nil
}
oldestScheduledFor := time.UnixMilli(int64(values[0].Score)).UTC()
snapshot.OldestScheduledFor = &oldestScheduledFor
return snapshot, nil
}
// SendingDeliveryIDs returns every delivery id currently indexed as
// `mail_delivery.status=sending`.
func (store *AttemptExecutionStore) SendingDeliveryIDs(ctx context.Context) ([]common.DeliveryID, error) {
if store == nil || store.client == nil {
return nil, errors.New("sending delivery ids: nil store")
}
if ctx == nil {
return nil, errors.New("sending delivery ids: nil context")
}
values, err := store.client.ZRange(ctx, store.keys.StatusIndex(deliverydomain.StatusSending), 0, -1).Result()
if err != nil {
return nil, fmt.Errorf("sending delivery ids: %w", err)
}
ids := make([]common.DeliveryID, len(values))
for index, value := range values {
ids[index] = common.DeliveryID(value)
}
return ids, nil
}
// RemoveScheduledDelivery removes deliveryID from the attempt schedule set.
func (store *AttemptExecutionStore) RemoveScheduledDelivery(ctx context.Context, deliveryID common.DeliveryID) error {
if store == nil || store.client == nil {
return errors.New("remove scheduled delivery: nil store")
}
if ctx == nil {
return errors.New("remove scheduled delivery: nil context")
}
if err := deliveryID.Validate(); err != nil {
return fmt.Errorf("remove scheduled delivery: %w", err)
}
if err := store.client.ZRem(ctx, store.keys.AttemptSchedule(), deliveryID.String()).Err(); err != nil {
return fmt.Errorf("remove scheduled delivery: %w", err)
}
return nil
}
// LoadWorkItem loads the current delivery and its latest attempt when both are
// present.
func (store *AttemptExecutionStore) LoadWorkItem(ctx context.Context, deliveryID common.DeliveryID) (executeattempt.WorkItem, bool, error) {
if store == nil || store.client == nil {
return executeattempt.WorkItem{}, false, errors.New("load attempt work item: nil store")
}
if ctx == nil {
return executeattempt.WorkItem{}, false, errors.New("load attempt work item: nil context")
}
if err := deliveryID.Validate(); err != nil {
return executeattempt.WorkItem{}, false, fmt.Errorf("load attempt work item: %w", err)
}
deliveryRecord, found, err := store.loadDelivery(ctx, deliveryID)
if err != nil || !found {
return executeattempt.WorkItem{}, found, err
}
if deliveryRecord.AttemptCount < 1 {
return executeattempt.WorkItem{}, false, nil
}
attemptRecord, found, err := store.loadAttempt(ctx, deliveryID, deliveryRecord.AttemptCount)
if err != nil || !found {
return executeattempt.WorkItem{}, found, err
}
return executeattempt.WorkItem{
Delivery: deliveryRecord,
Attempt: attemptRecord,
}, true, nil
}
// LoadPayload loads one stored raw attachment payload bundle.
func (store *AttemptExecutionStore) LoadPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
if store == nil || store.client == nil {
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("load attempt payload: nil store")
}
if ctx == nil {
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("load attempt payload: nil context")
}
if err := deliveryID.Validate(); err != nil {
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("load attempt payload: %w", err)
}
payload, err := store.client.Get(ctx, store.keys.DeliveryPayload(deliveryID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return acceptgenericdelivery.DeliveryPayload{}, false, nil
case err != nil:
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("load attempt payload: %w", err)
}
record, err := UnmarshalDeliveryPayload(payload)
if err != nil {
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("load attempt payload: %w", err)
}
return record, true, nil
}
// ClaimDueAttempt transitions one due scheduled attempt into `in_progress`
// ownership and returns the claimed work item.
func (store *AttemptExecutionStore) ClaimDueAttempt(ctx context.Context, deliveryID common.DeliveryID, now time.Time) (executeattempt.WorkItem, bool, error) {
if store == nil || store.client == nil {
return executeattempt.WorkItem{}, false, errors.New("claim due attempt: nil store")
}
if ctx == nil {
return executeattempt.WorkItem{}, false, errors.New("claim due attempt: nil context")
}
if err := deliveryID.Validate(); err != nil {
return executeattempt.WorkItem{}, false, fmt.Errorf("claim due attempt: %w", err)
}
claimedAt := now.UTC().Truncate(time.Millisecond)
if claimedAt.IsZero() {
return executeattempt.WorkItem{}, false, errors.New("claim due attempt: zero claim time")
}
deliveryKey := store.keys.Delivery(deliveryID)
var claimed executeattempt.WorkItem
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
deliveryRecord, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
switch {
case errors.Is(err, ErrConflict):
return errNotClaimable
case err != nil:
return fmt.Errorf("claim due attempt: %w", err)
}
if deliveryRecord.AttemptCount < 1 {
return errNotClaimable
}
attemptKey := store.keys.Attempt(deliveryID, deliveryRecord.AttemptCount)
attemptRecord, err := loadAttemptFromTx(ctx, tx, attemptKey)
switch {
case errors.Is(err, ErrConflict):
return errNotClaimable
case err != nil:
return fmt.Errorf("claim due attempt: %w", err)
}
score, err := tx.ZScore(ctx, store.keys.AttemptSchedule(), deliveryID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return errNotClaimable
case err != nil:
return fmt.Errorf("claim due attempt: read attempt schedule: %w", err)
}
switch deliveryRecord.Status {
case deliverydomain.StatusQueued, deliverydomain.StatusRendered:
default:
return errNotClaimable
}
if attemptRecord.Status != attempt.StatusScheduled {
return errNotClaimable
}
if score > ScheduledForScore(claimedAt) || attemptRecord.ScheduledFor.After(claimedAt) {
return errNotClaimable
}
claimedDelivery := deliveryRecord
claimedDelivery.Status = deliverydomain.StatusSending
claimedDelivery.UpdatedAt = claimedAt
if err := claimedDelivery.Validate(); err != nil {
return fmt.Errorf("claim due attempt: build claimed delivery: %w", err)
}
claimedAttempt := attemptRecord
claimedAttempt.Status = attempt.StatusInProgress
claimedAttempt.StartedAt = ptrTime(claimedAt)
if err := claimedAttempt.Validate(); err != nil {
return fmt.Errorf("claim due attempt: build claimed attempt: %w", err)
}
deliveryPayload, err := MarshalDelivery(claimedDelivery)
if err != nil {
return fmt.Errorf("claim due attempt: %w", err)
}
attemptPayload, err := MarshalAttempt(claimedAttempt)
if err != nil {
return fmt.Errorf("claim due attempt: %w", err)
}
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
if err != nil {
return fmt.Errorf("claim due attempt: delivery ttl: %w", err)
}
attemptTTL, err := ttlForExistingKey(ctx, tx, attemptKey, AttemptTTL)
if err != nil {
return fmt.Errorf("claim due attempt: attempt ttl: %w", err)
}
createdAtScore := CreatedAtScore(deliveryRecord.CreatedAt)
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
pipe.Set(ctx, attemptKey, attemptPayload, attemptTTL)
pipe.ZRem(ctx, store.keys.StatusIndex(deliveryRecord.Status), deliveryID.String())
pipe.ZAdd(ctx, store.keys.StatusIndex(deliverydomain.StatusSending), redis.Z{
Score: createdAtScore,
Member: deliveryID.String(),
})
pipe.ZRem(ctx, store.keys.AttemptSchedule(), deliveryID.String())
return nil
})
if err != nil {
return fmt.Errorf("claim due attempt: %w", err)
}
claimed = executeattempt.WorkItem{
Delivery: claimedDelivery,
Attempt: claimedAttempt,
}
return nil
}, deliveryKey)
switch {
case errors.Is(watchErr, errNotClaimable), errors.Is(watchErr, redis.TxFailedErr):
return executeattempt.WorkItem{}, false, nil
case watchErr != nil:
return executeattempt.WorkItem{}, false, watchErr
default:
return claimed, true, nil
}
}
// Commit atomically stores one complete attempt execution outcome.
func (store *AttemptExecutionStore) Commit(ctx context.Context, input executeattempt.CommitStateInput) error {
if store == nil || store.client == nil {
return errors.New("commit attempt outcome: nil store")
}
if ctx == nil {
return errors.New("commit attempt outcome: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
deliveryKey := store.keys.Delivery(input.Delivery.DeliveryID)
currentAttemptKey := store.keys.Attempt(input.Attempt.DeliveryID, input.Attempt.AttemptNo)
deliveryPayload, err := MarshalDelivery(input.Delivery)
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
attemptPayload, err := MarshalAttempt(input.Attempt)
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
var (
nextAttemptKey string
nextAttemptPayload []byte
nextAttemptScore float64
deadLetterKey string
deadLetterPayload []byte
)
if input.NextAttempt != nil {
nextAttemptKey = store.keys.Attempt(input.NextAttempt.DeliveryID, input.NextAttempt.AttemptNo)
nextAttemptPayload, err = MarshalAttempt(*input.NextAttempt)
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
nextAttemptScore = ScheduledForScore(input.NextAttempt.ScheduledFor)
}
if input.DeadLetter != nil {
deadLetterKey = store.keys.DeadLetter(input.DeadLetter.DeliveryID)
deadLetterPayload, err = MarshalDeadLetter(*input.DeadLetter)
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
}
watchKeys := []string{deliveryKey, currentAttemptKey}
if nextAttemptKey != "" {
watchKeys = append(watchKeys, nextAttemptKey)
}
if deadLetterKey != "" {
watchKeys = append(watchKeys, deadLetterKey)
}
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
currentDelivery, err := loadDeliveryFromTx(ctx, tx, deliveryKey)
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
currentAttempt, err := loadAttemptFromTx(ctx, tx, currentAttemptKey)
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
if currentDelivery.Status != deliverydomain.StatusSending {
return fmt.Errorf("commit attempt outcome: %w", ErrConflict)
}
if currentAttempt.Status != attempt.StatusInProgress {
return fmt.Errorf("commit attempt outcome: %w", ErrConflict)
}
if nextAttemptKey != "" {
if err := ensureKeyAbsent(ctx, tx, nextAttemptKey); err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
}
if deadLetterKey != "" {
if err := ensureKeyAbsent(ctx, tx, deadLetterKey); err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
}
deliveryTTL, err := ttlForExistingKey(ctx, tx, deliveryKey, DeliveryTTL)
if err != nil {
return fmt.Errorf("commit attempt outcome: delivery ttl: %w", err)
}
attemptTTL, err := ttlForExistingKey(ctx, tx, currentAttemptKey, AttemptTTL)
if err != nil {
return fmt.Errorf("commit attempt outcome: attempt ttl: %w", err)
}
createdAtScore := CreatedAtScore(currentDelivery.CreatedAt)
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, deliveryKey, deliveryPayload, deliveryTTL)
pipe.Set(ctx, currentAttemptKey, attemptPayload, attemptTTL)
pipe.ZRem(ctx, store.keys.StatusIndex(currentDelivery.Status), input.Delivery.DeliveryID.String())
pipe.ZAdd(ctx, store.keys.StatusIndex(input.Delivery.Status), redis.Z{
Score: createdAtScore,
Member: input.Delivery.DeliveryID.String(),
})
pipe.ZRem(ctx, store.keys.AttemptSchedule(), input.Delivery.DeliveryID.String())
if nextAttemptKey != "" {
pipe.Set(ctx, nextAttemptKey, nextAttemptPayload, AttemptTTL)
pipe.ZAdd(ctx, store.keys.AttemptSchedule(), redis.Z{
Score: nextAttemptScore,
Member: input.Delivery.DeliveryID.String(),
})
}
if deadLetterKey != "" {
pipe.Set(ctx, deadLetterKey, deadLetterPayload, DeadLetterTTL)
}
return nil
})
if err != nil {
return fmt.Errorf("commit attempt outcome: %w", err)
}
return nil
}, watchKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("commit attempt outcome: %w", ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *AttemptExecutionStore) loadDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return deliverydomain.Delivery{}, false, nil
case err != nil:
return deliverydomain.Delivery{}, false, fmt.Errorf("load attempt delivery: %w", err)
}
record, err := UnmarshalDelivery(payload)
if err != nil {
return deliverydomain.Delivery{}, false, fmt.Errorf("load attempt delivery: %w", err)
}
return record, true, nil
}
func (store *AttemptExecutionStore) loadAttempt(ctx context.Context, deliveryID common.DeliveryID, attemptNo int) (attempt.Attempt, bool, error) {
payload, err := store.client.Get(ctx, store.keys.Attempt(deliveryID, attemptNo)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return attempt.Attempt{}, false, nil
case err != nil:
return attempt.Attempt{}, false, fmt.Errorf("load attempt record: %w", err)
}
record, err := UnmarshalAttempt(payload)
if err != nil {
return attempt.Attempt{}, false, fmt.Errorf("load attempt record: %w", err)
}
return record, true, nil
}
func ptrTime(value time.Time) *time.Time {
return &value
}
@@ -0,0 +1,301 @@
package redisstate
import (
"context"
"sync"
"testing"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/service/executeattempt"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestAttemptExecutionStoreClaimDueAttemptTransitionsState(t *testing.T) {
t.Parallel()
server, client, store := newAttemptExecutionFixture(t)
record := queuedRenderedDelivery(t, common.DeliveryID("delivery-claim"))
createAcceptedDelivery(t, store, record)
claimed, found, err := store.ClaimDueAttempt(context.Background(), record.DeliveryID, record.CreatedAt.Add(time.Minute))
require.NoError(t, err)
require.True(t, found)
require.Equal(t, deliverydomain.StatusSending, claimed.Delivery.Status)
require.Equal(t, attempt.StatusInProgress, claimed.Attempt.Status)
require.NotNil(t, claimed.Attempt.StartedAt)
require.False(t, server.Exists(Keyspace{}.AttemptSchedule()))
storedDelivery, err := client.Get(context.Background(), Keyspace{}.Delivery(record.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDelivery, err := UnmarshalDelivery(storedDelivery)
require.NoError(t, err)
require.Equal(t, claimed.Delivery, decodedDelivery)
sendingMembers, err := client.ZRange(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusSending), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{record.DeliveryID.String()}, sendingMembers)
}
func TestAttemptExecutionStoreClaimDueAttemptAllowsOnlyOneOwner(t *testing.T) {
t.Parallel()
_, _, store := newAttemptExecutionFixture(t)
record := queuedRenderedDelivery(t, common.DeliveryID("delivery-race"))
createAcceptedDelivery(t, store, record)
const contenders = 8
var (
waitGroup sync.WaitGroup
mu sync.Mutex
successes int
)
for range contenders {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
_, found, err := store.ClaimDueAttempt(context.Background(), record.DeliveryID, record.CreatedAt.Add(time.Minute))
require.NoError(t, err)
mu.Lock()
defer mu.Unlock()
if found {
successes++
}
}()
}
waitGroup.Wait()
require.Equal(t, 1, successes)
}
func TestAttemptExecutionStoreCommitSchedulesRetry(t *testing.T) {
t.Parallel()
_, client, store := newAttemptExecutionFixture(t)
workItem := inProgressWorkItem(t, common.DeliveryID("delivery-retry"), 1)
seedWorkItemState(t, client, workItem)
finishedAt := workItem.Attempt.StartedAt.Add(30 * time.Second)
currentAttempt := workItem.Attempt
currentAttempt.Status = attempt.StatusTransportFailed
currentAttempt.FinishedAt = ptrTimeAttemptStore(finishedAt)
currentAttempt.ProviderClassification = "transient_failure"
currentAttempt.ProviderSummary = "provider=smtp result=transient_failure phase=data smtp_code=451"
require.NoError(t, currentAttempt.Validate())
nextAttempt := attempt.Attempt{
DeliveryID: workItem.Delivery.DeliveryID,
AttemptNo: 2,
ScheduledFor: finishedAt.Add(time.Minute),
Status: attempt.StatusScheduled,
}
require.NoError(t, nextAttempt.Validate())
deliveryRecord := workItem.Delivery
deliveryRecord.Status = deliverydomain.StatusQueued
deliveryRecord.AttemptCount = nextAttempt.AttemptNo
deliveryRecord.LastAttemptStatus = currentAttempt.Status
deliveryRecord.ProviderSummary = currentAttempt.ProviderSummary
deliveryRecord.UpdatedAt = finishedAt
require.NoError(t, deliveryRecord.Validate())
input := executeattempt.CommitStateInput{
Delivery: deliveryRecord,
Attempt: currentAttempt,
NextAttempt: &nextAttempt,
}
require.NoError(t, input.Validate())
require.NoError(t, store.Commit(context.Background(), input))
reloaded, found, err := store.LoadWorkItem(context.Background(), workItem.Delivery.DeliveryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, deliveryRecord, reloaded.Delivery)
require.Equal(t, nextAttempt, reloaded.Attempt)
firstAttemptPayload, err := client.Get(context.Background(), Keyspace{}.Attempt(workItem.Delivery.DeliveryID, 1)).Bytes()
require.NoError(t, err)
firstAttemptRecord, err := UnmarshalAttempt(firstAttemptPayload)
require.NoError(t, err)
require.Equal(t, currentAttempt, firstAttemptRecord)
scheduledMembers, err := client.ZRange(context.Background(), Keyspace{}.AttemptSchedule(), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{workItem.Delivery.DeliveryID.String()}, scheduledMembers)
}
func TestAttemptExecutionStoreCommitCreatesDeadLetter(t *testing.T) {
t.Parallel()
_, client, store := newAttemptExecutionFixture(t)
workItem := inProgressWorkItem(t, common.DeliveryID("delivery-dead-letter"), 4)
seedWorkItemState(t, client, workItem)
finishedAt := workItem.Attempt.StartedAt.Add(30 * time.Second)
currentAttempt := workItem.Attempt
currentAttempt.Status = attempt.StatusTimedOut
currentAttempt.FinishedAt = ptrTimeAttemptStore(finishedAt)
currentAttempt.ProviderClassification = "deadline_exceeded"
currentAttempt.ProviderSummary = "attempt claim TTL expired"
require.NoError(t, currentAttempt.Validate())
deliveryRecord := workItem.Delivery
deliveryRecord.Status = deliverydomain.StatusDeadLetter
deliveryRecord.LastAttemptStatus = currentAttempt.Status
deliveryRecord.ProviderSummary = currentAttempt.ProviderSummary
deliveryRecord.UpdatedAt = finishedAt
deliveryRecord.DeadLetteredAt = ptrTimeAttemptStore(finishedAt)
require.NoError(t, deliveryRecord.Validate())
deadLetter := &deliverydomain.DeadLetterEntry{
DeliveryID: deliveryRecord.DeliveryID,
FinalAttemptNo: currentAttempt.AttemptNo,
FailureClassification: "retry_exhausted",
ProviderSummary: currentAttempt.ProviderSummary,
CreatedAt: finishedAt,
RecoveryHint: "check SMTP connectivity",
}
require.NoError(t, deadLetter.ValidateFor(deliveryRecord))
input := executeattempt.CommitStateInput{
Delivery: deliveryRecord,
Attempt: currentAttempt,
DeadLetter: deadLetter,
}
require.NoError(t, input.Validate())
require.NoError(t, store.Commit(context.Background(), input))
storedDelivery, found, err := store.LoadWorkItem(context.Background(), workItem.Delivery.DeliveryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, deliveryRecord, storedDelivery.Delivery)
require.Equal(t, currentAttempt, storedDelivery.Attempt)
deadLetterPayload, err := client.Get(context.Background(), Keyspace{}.DeadLetter(workItem.Delivery.DeliveryID)).Bytes()
require.NoError(t, err)
decodedDeadLetter, err := UnmarshalDeadLetter(deadLetterPayload)
require.NoError(t, err)
require.Equal(t, *deadLetter, decodedDeadLetter)
}
func newAttemptExecutionFixture(t *testing.T) (*miniredis.Miniredis, *redis.Client, *AttemptExecutionStore) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewAttemptExecutionStore(client)
require.NoError(t, err)
return server, client, store
}
func createAcceptedDelivery(t *testing.T, store *AttemptExecutionStore, record deliverydomain.Delivery) {
t.Helper()
client := store.client
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
firstAttempt := attempt.Attempt{
DeliveryID: record.DeliveryID,
AttemptNo: 1,
ScheduledFor: record.CreatedAt,
Status: attempt.StatusScheduled,
}
require.NoError(t, firstAttempt.Validate())
require.NoError(t, writer.CreateAcceptance(context.Background(), CreateAcceptanceInput{
Delivery: record,
FirstAttempt: &firstAttempt,
}))
}
func queuedRenderedDelivery(t *testing.T, deliveryID common.DeliveryID) deliverydomain.Delivery {
t.Helper()
record := validDelivery(t)
record.DeliveryID = deliveryID
record.ResendParentDeliveryID = ""
record.Source = deliverydomain.SourceNotification
record.PayloadMode = deliverydomain.PayloadModeRendered
record.TemplateID = ""
record.Locale = ""
record.TemplateVariables = nil
record.LocaleFallbackUsed = false
record.Attachments = nil
record.Status = deliverydomain.StatusQueued
record.AttemptCount = 1
record.LastAttemptStatus = ""
record.ProviderSummary = ""
record.CreatedAt = time.Unix(1_775_121_700, 0).UTC()
record.UpdatedAt = record.CreatedAt
record.SentAt = nil
record.SuppressedAt = nil
record.FailedAt = nil
record.DeadLetteredAt = nil
record.IdempotencyKey = common.IdempotencyKey("notification:" + deliveryID.String())
require.NoError(t, record.Validate())
return record
}
func inProgressWorkItem(t *testing.T, deliveryID common.DeliveryID, attemptNo int) executeattempt.WorkItem {
t.Helper()
deliveryRecord := queuedRenderedDelivery(t, deliveryID)
deliveryRecord.Status = deliverydomain.StatusSending
deliveryRecord.AttemptCount = attemptNo
deliveryRecord.UpdatedAt = deliveryRecord.CreatedAt.Add(time.Duration(attemptNo) * time.Minute)
require.NoError(t, deliveryRecord.Validate())
scheduledFor := deliveryRecord.CreatedAt.Add(time.Duration(attemptNo-1) * time.Minute)
startedAt := scheduledFor.Add(5 * time.Second)
attemptRecord := attempt.Attempt{
DeliveryID: deliveryID,
AttemptNo: attemptNo,
ScheduledFor: scheduledFor,
StartedAt: &startedAt,
Status: attempt.StatusInProgress,
}
require.NoError(t, attemptRecord.Validate())
return executeattempt.WorkItem{
Delivery: deliveryRecord,
Attempt: attemptRecord,
}
}
func seedWorkItemState(t *testing.T, client *redis.Client, item executeattempt.WorkItem) {
t.Helper()
deliveryPayload, err := MarshalDelivery(item.Delivery)
require.NoError(t, err)
attemptPayload, err := MarshalAttempt(item.Attempt)
require.NoError(t, err)
err = client.Set(context.Background(), Keyspace{}.Delivery(item.Delivery.DeliveryID), deliveryPayload, DeliveryTTL).Err()
require.NoError(t, err)
err = client.Set(context.Background(), Keyspace{}.Attempt(item.Attempt.DeliveryID, item.Attempt.AttemptNo), attemptPayload, AttemptTTL).Err()
require.NoError(t, err)
err = client.ZAdd(context.Background(), Keyspace{}.StatusIndex(deliverydomain.StatusSending), redis.Z{
Score: CreatedAtScore(item.Delivery.CreatedAt),
Member: item.Delivery.DeliveryID.String(),
}).Err()
require.NoError(t, err)
}
func ptrTimeAttemptStore(value time.Time) *time.Time {
return &value
}
@@ -0,0 +1,117 @@
package redisstate
import (
"context"
"errors"
"fmt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/service/acceptauthdelivery"
"github.com/redis/go-redis/v9"
)
// AcceptanceStore provides the Redis-backed durable storage used by the
// auth-delivery acceptance use case.
type AcceptanceStore struct {
client *redis.Client
writer *AtomicWriter
keys Keyspace
}
// NewAcceptanceStore constructs one Redis-backed auth acceptance store.
func NewAcceptanceStore(client *redis.Client) (*AcceptanceStore, error) {
if client == nil {
return nil, errors.New("new auth acceptance store: nil redis client")
}
writer, err := NewAtomicWriter(client)
if err != nil {
return nil, fmt.Errorf("new auth acceptance store: %w", err)
}
return &AcceptanceStore{
client: client,
writer: writer,
keys: Keyspace{},
}, nil
}
// CreateAcceptance stores one auth-delivery acceptance write set in Redis.
func (store *AcceptanceStore) CreateAcceptance(ctx context.Context, input acceptauthdelivery.CreateAcceptanceInput) error {
if store == nil || store.client == nil || store.writer == nil {
return errors.New("create auth acceptance: nil store")
}
if ctx == nil {
return errors.New("create auth acceptance: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("create auth acceptance: %w", err)
}
err := store.writer.CreateAcceptance(ctx, CreateAcceptanceInput{
Delivery: input.Delivery,
FirstAttempt: input.FirstAttempt,
Idempotency: &input.Idempotency,
})
if errors.Is(err, ErrConflict) {
return fmt.Errorf("create auth acceptance: %w", acceptauthdelivery.ErrConflict)
}
if err != nil {
return fmt.Errorf("create auth acceptance: %w", err)
}
return nil
}
// GetIdempotency loads one accepted idempotency scope from Redis.
func (store *AcceptanceStore) GetIdempotency(ctx context.Context, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
if store == nil || store.client == nil {
return idempotency.Record{}, false, errors.New("get auth acceptance idempotency: nil store")
}
if ctx == nil {
return idempotency.Record{}, false, errors.New("get auth acceptance idempotency: nil context")
}
payload, err := store.client.Get(ctx, store.keys.Idempotency(source, key)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return idempotency.Record{}, false, nil
case err != nil:
return idempotency.Record{}, false, fmt.Errorf("get auth acceptance idempotency: %w", err)
}
record, err := UnmarshalIdempotency(payload)
if err != nil {
return idempotency.Record{}, false, fmt.Errorf("get auth acceptance idempotency: %w", err)
}
return record, true, nil
}
// GetDelivery loads one accepted delivery from Redis.
func (store *AcceptanceStore) GetDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
if store == nil || store.client == nil {
return deliverydomain.Delivery{}, false, errors.New("get auth acceptance delivery: nil store")
}
if ctx == nil {
return deliverydomain.Delivery{}, false, errors.New("get auth acceptance delivery: nil context")
}
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return deliverydomain.Delivery{}, false, nil
case err != nil:
return deliverydomain.Delivery{}, false, fmt.Errorf("get auth acceptance delivery: %w", err)
}
record, err := UnmarshalDelivery(payload)
if err != nil {
return deliverydomain.Delivery{}, false, fmt.Errorf("get auth acceptance delivery: %w", err)
}
return record, true, nil
}
@@ -0,0 +1,117 @@
package redisstate
import (
"context"
"testing"
"time"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/service/acceptauthdelivery"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestAcceptanceStoreCreateAndReadQueuedDelivery(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewAcceptanceStore(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceAuthSession
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.AttemptCount = 1
record.LastAttemptStatus = ""
record.ProviderSummary = ""
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt
record.SentAt = nil
require.NoError(t, record.Validate())
input := acceptauthdelivery.CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
Idempotency: validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey),
}
require.NoError(t, store.CreateAcceptance(context.Background(), input))
storedDelivery, found, err := store.GetDelivery(context.Background(), record.DeliveryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, record, storedDelivery)
storedIdempotency, found, err := store.GetIdempotency(context.Background(), record.Source, record.IdempotencyKey)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, input.Idempotency, storedIdempotency)
}
func TestAcceptanceStoreCreateAndReadSuppressedDelivery(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewAcceptanceStore(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceAuthSession
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusSuppressed
record.AttemptCount = 0
record.LastAttemptStatus = ""
record.ProviderSummary = ""
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
record.SentAt = nil
record.SuppressedAt = ptr(record.UpdatedAt)
require.NoError(t, record.Validate())
input := acceptauthdelivery.CreateAcceptanceInput{
Delivery: record,
Idempotency: validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey),
}
require.NoError(t, store.CreateAcceptance(context.Background(), input))
storedDelivery, found, err := store.GetDelivery(context.Background(), record.DeliveryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, record, storedDelivery)
attemptExists := server.Exists(Keyspace{}.Attempt(record.DeliveryID, 1))
require.False(t, attemptExists)
}
func TestAcceptanceStoreReturnsNotFound(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewAcceptanceStore(client)
require.NoError(t, err)
deliveryRecord, found, err := store.GetDelivery(context.Background(), common.DeliveryID("missing"))
require.NoError(t, err)
require.False(t, found)
require.Equal(t, deliverydomain.Delivery{}, deliveryRecord)
idempotencyRecord, found, err := store.GetIdempotency(context.Background(), deliverydomain.SourceAuthSession, common.IdempotencyKey("missing"))
require.NoError(t, err)
require.False(t, found)
require.Equal(t, idempotency.Record{}, idempotencyRecord)
}
+697
View File
@@ -0,0 +1,697 @@
package redisstate
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/domain/malformedcommand"
"galaxy/mail/internal/service/acceptgenericdelivery"
)
type deliveryRecord struct {
DeliveryID string `json:"delivery_id"`
ResendParentDeliveryID string `json:"resend_parent_delivery_id,omitempty"`
Source deliverydomain.Source `json:"source"`
PayloadMode deliverydomain.PayloadMode `json:"payload_mode"`
TemplateID string `json:"template_id,omitempty"`
TemplateVariables *map[string]any `json:"template_variables,omitempty"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
ReplyTo []string `json:"reply_to"`
Subject string `json:"subject,omitempty"`
TextBody string `json:"text_body,omitempty"`
HTMLBody string `json:"html_body,omitempty"`
Attachments []attachmentRecord `json:"attachments"`
Locale string `json:"locale,omitempty"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
IdempotencyKey string `json:"idempotency_key"`
Status deliverydomain.Status `json:"status"`
AttemptCount int `json:"attempt_count"`
LastAttemptStatus attempt.Status `json:"last_attempt_status,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
SentAtMS *int64 `json:"sent_at_ms,omitempty"`
SuppressedAtMS *int64 `json:"suppressed_at_ms,omitempty"`
FailedAtMS *int64 `json:"failed_at_ms,omitempty"`
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
}
type attemptRecord struct {
DeliveryID string `json:"delivery_id"`
AttemptNo int `json:"attempt_no"`
ScheduledForMS int64 `json:"scheduled_for_ms"`
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
Status attempt.Status `json:"status"`
ProviderClassification string `json:"provider_classification,omitempty"`
ProviderSummary string `json:"provider_summary,omitempty"`
}
type idempotencyRecord struct {
Source deliverydomain.Source `json:"source"`
IdempotencyKey string `json:"idempotency_key"`
DeliveryID string `json:"delivery_id"`
RequestFingerprint string `json:"request_fingerprint"`
CreatedAtMS int64 `json:"created_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
}
type deadLetterRecord struct {
DeliveryID string `json:"delivery_id"`
FinalAttemptNo int `json:"final_attempt_no"`
FailureClassification string `json:"failure_classification"`
ProviderSummary string `json:"provider_summary,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
RecoveryHint string `json:"recovery_hint,omitempty"`
}
type deliveryPayloadRecord struct {
DeliveryID string `json:"delivery_id"`
Attachments []deliveryPayloadAttachmentRecord `json:"attachments"`
}
type deliveryPayloadAttachmentRecord struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ContentBase64 string `json:"content_base64"`
SizeBytes int64 `json:"size_bytes"`
}
type malformedCommandRecord struct {
StreamEntryID string `json:"stream_entry_id"`
DeliveryID string `json:"delivery_id,omitempty"`
Source string `json:"source,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
FailureCode malformedcommand.FailureCode `json:"failure_code"`
FailureMessage string `json:"failure_message"`
RawFieldsJSON map[string]any `json:"raw_fields_json"`
RecordedAtMS int64 `json:"recorded_at_ms"`
}
type streamOffsetRecord struct {
Stream string `json:"stream"`
LastProcessedEntryID string `json:"last_processed_entry_id"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// StreamOffset stores the persisted progress of one plain-XREAD consumer.
type StreamOffset struct {
// Stream stores the Redis Stream name.
Stream string
// LastProcessedEntryID stores the last durably processed entry id.
LastProcessedEntryID string
// UpdatedAt stores when the offset was updated.
UpdatedAt time.Time
}
// Validate reports whether offset contains a complete persisted progress
// record.
func (offset StreamOffset) Validate() error {
if strings.TrimSpace(offset.Stream) == "" {
return fmt.Errorf("stream offset stream must not be empty")
}
if strings.TrimSpace(offset.LastProcessedEntryID) == "" {
return fmt.Errorf("stream offset last processed entry id must not be empty")
}
if err := common.ValidateTimestamp("stream offset updated at", offset.UpdatedAt); err != nil {
return err
}
return nil
}
type attachmentRecord struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
// MarshalDelivery encodes record into the strict Redis JSON shape used for
// mail_delivery records.
func MarshalDelivery(record deliverydomain.Delivery) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis delivery record: %w", err)
}
stored := deliveryRecord{
DeliveryID: record.DeliveryID.String(),
ResendParentDeliveryID: record.ResendParentDeliveryID.String(),
Source: record.Source,
PayloadMode: record.PayloadMode,
TemplateID: record.TemplateID.String(),
TemplateVariables: optionalJSONObject(record.TemplateVariables),
To: cloneEmailStrings(record.Envelope.To),
Cc: cloneEmailStrings(record.Envelope.Cc),
Bcc: cloneEmailStrings(record.Envelope.Bcc),
ReplyTo: cloneEmailStrings(record.Envelope.ReplyTo),
Subject: record.Content.Subject,
TextBody: record.Content.TextBody,
HTMLBody: record.Content.HTMLBody,
Attachments: cloneAttachments(record.Attachments),
Locale: record.Locale.String(),
LocaleFallbackUsed: record.LocaleFallbackUsed,
IdempotencyKey: record.IdempotencyKey.String(),
Status: record.Status,
AttemptCount: record.AttemptCount,
LastAttemptStatus: record.LastAttemptStatus,
ProviderSummary: record.ProviderSummary,
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
SentAtMS: optionalUnixMilli(record.SentAt),
SuppressedAtMS: optionalUnixMilli(record.SuppressedAt),
FailedAtMS: optionalUnixMilli(record.FailedAt),
DeadLetteredAtMS: optionalUnixMilli(record.DeadLetteredAt),
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis delivery record: %w", err)
}
return payload, nil
}
// UnmarshalDelivery decodes payload from the strict Redis JSON shape used for
// mail_delivery records.
func UnmarshalDelivery(payload []byte) (deliverydomain.Delivery, error) {
var stored deliveryRecord
if err := decodeStrictJSON("decode redis delivery record", payload, &stored); err != nil {
return deliverydomain.Delivery{}, err
}
record := deliverydomain.Delivery{
DeliveryID: common.DeliveryID(stored.DeliveryID),
ResendParentDeliveryID: common.DeliveryID(stored.ResendParentDeliveryID),
Source: stored.Source,
PayloadMode: stored.PayloadMode,
TemplateID: common.TemplateID(stored.TemplateID),
TemplateVariables: cloneJSONObjectPtr(stored.TemplateVariables),
Envelope: deliverydomain.Envelope{
To: cloneEmails(stored.To),
Cc: cloneEmails(stored.Cc),
Bcc: cloneEmails(stored.Bcc),
ReplyTo: cloneEmails(stored.ReplyTo),
},
Content: deliverydomain.Content{
Subject: stored.Subject,
TextBody: stored.TextBody,
HTMLBody: stored.HTMLBody,
},
Attachments: inflateAttachments(stored.Attachments),
Locale: common.Locale(stored.Locale),
LocaleFallbackUsed: stored.LocaleFallbackUsed,
IdempotencyKey: common.IdempotencyKey(stored.IdempotencyKey),
Status: stored.Status,
AttemptCount: stored.AttemptCount,
LastAttemptStatus: stored.LastAttemptStatus,
ProviderSummary: stored.ProviderSummary,
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
SentAt: inflateOptionalTime(stored.SentAtMS),
SuppressedAt: inflateOptionalTime(stored.SuppressedAtMS),
FailedAt: inflateOptionalTime(stored.FailedAtMS),
DeadLetteredAt: inflateOptionalTime(stored.DeadLetteredAtMS),
}
if err := record.Validate(); err != nil {
return deliverydomain.Delivery{}, fmt.Errorf("decode redis delivery record: %w", err)
}
return record, nil
}
// MarshalAttempt encodes record into the strict Redis JSON shape used for
// mail_attempt records.
func MarshalAttempt(record attempt.Attempt) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis attempt record: %w", err)
}
stored := attemptRecord{
DeliveryID: record.DeliveryID.String(),
AttemptNo: record.AttemptNo,
ScheduledForMS: record.ScheduledFor.UTC().UnixMilli(),
StartedAtMS: optionalUnixMilli(record.StartedAt),
FinishedAtMS: optionalUnixMilli(record.FinishedAt),
Status: record.Status,
ProviderClassification: record.ProviderClassification,
ProviderSummary: record.ProviderSummary,
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis attempt record: %w", err)
}
return payload, nil
}
// UnmarshalAttempt decodes payload from the strict Redis JSON shape used for
// mail_attempt records.
func UnmarshalAttempt(payload []byte) (attempt.Attempt, error) {
var stored attemptRecord
if err := decodeStrictJSON("decode redis attempt record", payload, &stored); err != nil {
return attempt.Attempt{}, err
}
record := attempt.Attempt{
DeliveryID: common.DeliveryID(stored.DeliveryID),
AttemptNo: stored.AttemptNo,
ScheduledFor: time.UnixMilli(stored.ScheduledForMS).UTC(),
StartedAt: inflateOptionalTime(stored.StartedAtMS),
FinishedAt: inflateOptionalTime(stored.FinishedAtMS),
Status: stored.Status,
ProviderClassification: stored.ProviderClassification,
ProviderSummary: stored.ProviderSummary,
}
if err := record.Validate(); err != nil {
return attempt.Attempt{}, fmt.Errorf("decode redis attempt record: %w", err)
}
return record, nil
}
// MarshalIdempotency encodes record into the strict Redis JSON shape used for
// mail_idempotency_record values.
func MarshalIdempotency(record idempotency.Record) ([]byte, error) {
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis idempotency record: %w", err)
}
stored := idempotencyRecord{
Source: record.Source,
IdempotencyKey: record.IdempotencyKey.String(),
DeliveryID: record.DeliveryID.String(),
RequestFingerprint: record.RequestFingerprint,
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
ExpiresAtMS: record.ExpiresAt.UTC().UnixMilli(),
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis idempotency record: %w", err)
}
return payload, nil
}
// UnmarshalIdempotency decodes payload from the strict Redis JSON shape used
// for mail_idempotency_record values.
func UnmarshalIdempotency(payload []byte) (idempotency.Record, error) {
var stored idempotencyRecord
if err := decodeStrictJSON("decode redis idempotency record", payload, &stored); err != nil {
return idempotency.Record{}, err
}
record := idempotency.Record{
Source: stored.Source,
IdempotencyKey: common.IdempotencyKey(stored.IdempotencyKey),
DeliveryID: common.DeliveryID(stored.DeliveryID),
RequestFingerprint: stored.RequestFingerprint,
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
ExpiresAt: time.UnixMilli(stored.ExpiresAtMS).UTC(),
}
if err := record.Validate(); err != nil {
return idempotency.Record{}, fmt.Errorf("decode redis idempotency record: %w", err)
}
return record, nil
}
// MarshalDeadLetter encodes entry into the strict Redis JSON shape used for
// mail_dead_letter_entry values.
func MarshalDeadLetter(entry deliverydomain.DeadLetterEntry) ([]byte, error) {
if err := entry.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis dead-letter record: %w", err)
}
stored := deadLetterRecord{
DeliveryID: entry.DeliveryID.String(),
FinalAttemptNo: entry.FinalAttemptNo,
FailureClassification: entry.FailureClassification,
ProviderSummary: entry.ProviderSummary,
CreatedAtMS: entry.CreatedAt.UTC().UnixMilli(),
RecoveryHint: entry.RecoveryHint,
}
payload, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis dead-letter record: %w", err)
}
return payload, nil
}
// UnmarshalDeadLetter decodes payload from the strict Redis JSON shape used
// for mail_dead_letter_entry values.
func UnmarshalDeadLetter(payload []byte) (deliverydomain.DeadLetterEntry, error) {
var stored deadLetterRecord
if err := decodeStrictJSON("decode redis dead-letter record", payload, &stored); err != nil {
return deliverydomain.DeadLetterEntry{}, err
}
entry := deliverydomain.DeadLetterEntry{
DeliveryID: common.DeliveryID(stored.DeliveryID),
FinalAttemptNo: stored.FinalAttemptNo,
FailureClassification: stored.FailureClassification,
ProviderSummary: stored.ProviderSummary,
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
RecoveryHint: stored.RecoveryHint,
}
if err := entry.Validate(); err != nil {
return deliverydomain.DeadLetterEntry{}, fmt.Errorf("decode redis dead-letter record: %w", err)
}
return entry, nil
}
// MarshalDeliveryPayload encodes payload into the strict Redis JSON shape used
// for raw generic-delivery attachment bundles.
func MarshalDeliveryPayload(payload acceptgenericdelivery.DeliveryPayload) ([]byte, error) {
if err := payload.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis delivery payload record: %w", err)
}
stored := deliveryPayloadRecord{
DeliveryID: payload.DeliveryID.String(),
Attachments: cloneDeliveryPayloadAttachments(payload.Attachments),
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis delivery payload record: %w", err)
}
return encoded, nil
}
// UnmarshalDeliveryPayload decodes payload from the strict Redis JSON shape
// used for raw generic-delivery attachment bundles.
func UnmarshalDeliveryPayload(payload []byte) (acceptgenericdelivery.DeliveryPayload, error) {
var stored deliveryPayloadRecord
if err := decodeStrictJSON("decode redis delivery payload record", payload, &stored); err != nil {
return acceptgenericdelivery.DeliveryPayload{}, err
}
record := acceptgenericdelivery.DeliveryPayload{
DeliveryID: common.DeliveryID(stored.DeliveryID),
Attachments: inflateDeliveryPayloadAttachments(stored.Attachments),
}
if err := record.Validate(); err != nil {
return acceptgenericdelivery.DeliveryPayload{}, fmt.Errorf("decode redis delivery payload record: %w", err)
}
return record, nil
}
// MarshalMalformedCommand encodes entry into the strict Redis JSON shape used
// for operator-visible malformed async command records.
func MarshalMalformedCommand(entry malformedcommand.Entry) ([]byte, error) {
if err := entry.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis malformed command record: %w", err)
}
stored := malformedCommandRecord{
StreamEntryID: entry.StreamEntryID,
DeliveryID: entry.DeliveryID,
Source: entry.Source,
IdempotencyKey: entry.IdempotencyKey,
FailureCode: entry.FailureCode,
FailureMessage: entry.FailureMessage,
RawFieldsJSON: cloneJSONObject(entry.RawFields),
RecordedAtMS: entry.RecordedAt.UTC().UnixMilli(),
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis malformed command record: %w", err)
}
return encoded, nil
}
// UnmarshalMalformedCommand decodes payload from the strict Redis JSON shape
// used for operator-visible malformed async command records.
func UnmarshalMalformedCommand(payload []byte) (malformedcommand.Entry, error) {
var stored malformedCommandRecord
if err := decodeStrictJSON("decode redis malformed command record", payload, &stored); err != nil {
return malformedcommand.Entry{}, err
}
entry := malformedcommand.Entry{
StreamEntryID: stored.StreamEntryID,
DeliveryID: stored.DeliveryID,
Source: stored.Source,
IdempotencyKey: stored.IdempotencyKey,
FailureCode: stored.FailureCode,
FailureMessage: stored.FailureMessage,
RawFields: cloneJSONObject(stored.RawFieldsJSON),
RecordedAt: time.UnixMilli(stored.RecordedAtMS).UTC(),
}
if err := entry.Validate(); err != nil {
return malformedcommand.Entry{}, fmt.Errorf("decode redis malformed command record: %w", err)
}
return entry, nil
}
// MarshalStreamOffset encodes offset into the strict Redis JSON shape used for
// persisted consumer progress.
func MarshalStreamOffset(offset StreamOffset) ([]byte, error) {
if err := offset.Validate(); err != nil {
return nil, fmt.Errorf("marshal redis stream offset record: %w", err)
}
stored := streamOffsetRecord{
Stream: offset.Stream,
LastProcessedEntryID: offset.LastProcessedEntryID,
UpdatedAtMS: offset.UpdatedAt.UTC().UnixMilli(),
}
encoded, err := json.Marshal(stored)
if err != nil {
return nil, fmt.Errorf("marshal redis stream offset record: %w", err)
}
return encoded, nil
}
// UnmarshalStreamOffset decodes payload from the strict Redis JSON shape used
// for persisted consumer progress.
func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) {
var stored streamOffsetRecord
if err := decodeStrictJSON("decode redis stream offset record", payload, &stored); err != nil {
return StreamOffset{}, err
}
offset := StreamOffset{
Stream: stored.Stream,
LastProcessedEntryID: stored.LastProcessedEntryID,
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
}
if err := offset.Validate(); err != nil {
return StreamOffset{}, fmt.Errorf("decode redis stream offset record: %w", err)
}
return offset, nil
}
func decodeStrictJSON(operation string, payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("%s: unexpected trailing JSON input", operation)
}
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
func cloneEmailStrings(values []common.Email) []string {
if values == nil {
return nil
}
cloned := make([]string, len(values))
for index, value := range values {
cloned[index] = value.String()
}
return cloned
}
func cloneEmails(values []string) []common.Email {
if values == nil {
return nil
}
cloned := make([]common.Email, len(values))
for index, value := range values {
cloned[index] = common.Email(value)
}
return cloned
}
func cloneAttachments(values []common.AttachmentMetadata) []attachmentRecord {
if values == nil {
return nil
}
cloned := make([]attachmentRecord, len(values))
for index, value := range values {
cloned[index] = attachmentRecord{
Filename: value.Filename,
ContentType: value.ContentType,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func inflateAttachments(values []attachmentRecord) []common.AttachmentMetadata {
if values == nil {
return nil
}
cloned := make([]common.AttachmentMetadata, len(values))
for index, value := range values {
cloned[index] = common.AttachmentMetadata{
Filename: value.Filename,
ContentType: value.ContentType,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func optionalJSONObject(value map[string]any) *map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(value))
for key, item := range value {
cloned[key] = cloneJSONValue(item)
}
return &cloned
}
func cloneJSONObjectPtr(value *map[string]any) map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(*value))
for key, item := range *value {
cloned[key] = cloneJSONValue(item)
}
return cloned
}
func cloneJSONObject(value map[string]any) map[string]any {
if value == nil {
return nil
}
cloned := make(map[string]any, len(value))
for key, item := range value {
cloned[key] = cloneJSONValue(item)
}
return cloned
}
func cloneJSONValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, item := range typed {
cloned[key] = cloneJSONValue(item)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = cloneJSONValue(item)
}
return cloned
default:
return typed
}
}
func cloneDeliveryPayloadAttachments(values []acceptgenericdelivery.AttachmentPayload) []deliveryPayloadAttachmentRecord {
if values == nil {
return nil
}
cloned := make([]deliveryPayloadAttachmentRecord, len(values))
for index, value := range values {
cloned[index] = deliveryPayloadAttachmentRecord{
Filename: value.Filename,
ContentType: value.ContentType,
ContentBase64: value.ContentBase64,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func inflateDeliveryPayloadAttachments(values []deliveryPayloadAttachmentRecord) []acceptgenericdelivery.AttachmentPayload {
if values == nil {
return nil
}
cloned := make([]acceptgenericdelivery.AttachmentPayload, len(values))
for index, value := range values {
cloned[index] = acceptgenericdelivery.AttachmentPayload{
Filename: value.Filename,
ContentType: value.ContentType,
ContentBase64: value.ContentBase64,
SizeBytes: value.SizeBytes,
}
}
return cloned
}
func optionalUnixMilli(value *time.Time) *int64 {
if value == nil {
return nil
}
milliseconds := value.UTC().UnixMilli()
return &milliseconds
}
func inflateOptionalTime(value *int64) *time.Time {
if value == nil {
return nil
}
converted := time.UnixMilli(*value).UTC()
return &converted
}
@@ -0,0 +1,124 @@
package redisstate
import (
"bytes"
"testing"
"galaxy/mail/internal/domain/attempt"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"github.com/stretchr/testify/require"
)
func TestDeliveryCodecRoundTrip(t *testing.T) {
t.Parallel()
record := validDelivery(t)
payload, err := MarshalDelivery(record)
require.NoError(t, err)
decoded, err := UnmarshalDelivery(payload)
require.NoError(t, err)
require.Equal(t, record, decoded)
}
func TestAttemptCodecRoundTrip(t *testing.T) {
t.Parallel()
record := validTerminalAttempt(t, validDelivery(t).DeliveryID)
payload, err := MarshalAttempt(record)
require.NoError(t, err)
decoded, err := UnmarshalAttempt(payload)
require.NoError(t, err)
require.Equal(t, record, decoded)
}
func TestIdempotencyCodecRoundTrip(t *testing.T) {
t.Parallel()
deliveryRecord := validDelivery(t)
record := validIdempotencyRecord(t, deliveryRecord.Source, deliveryRecord.DeliveryID, deliveryRecord.IdempotencyKey)
payload, err := MarshalIdempotency(record)
require.NoError(t, err)
decoded, err := UnmarshalIdempotency(payload)
require.NoError(t, err)
require.Equal(t, record, decoded)
}
func TestDeadLetterCodecRoundTrip(t *testing.T) {
t.Parallel()
record := validDeadLetterEntry(t, validDelivery(t).DeliveryID)
payload, err := MarshalDeadLetter(record)
require.NoError(t, err)
decoded, err := UnmarshalDeadLetter(payload)
require.NoError(t, err)
require.Equal(t, record, decoded)
}
func TestDeliveryCodecRejectsUnknownField(t *testing.T) {
t.Parallel()
payload, err := MarshalDelivery(validDelivery(t))
require.NoError(t, err)
payload = append(payload[:len(payload)-1], []byte(`,"extra":true}`)...)
_, err = UnmarshalDelivery(payload)
require.Error(t, err)
require.ErrorContains(t, err, "unknown field")
}
func TestAttemptCodecRejectsWrongType(t *testing.T) {
t.Parallel()
payload, err := MarshalAttempt(validTerminalAttempt(t, validDelivery(t).DeliveryID))
require.NoError(t, err)
payload = bytes.Replace(payload, []byte(`"attempt_no":2`), []byte(`"attempt_no":"2"`), 1)
_, err = UnmarshalAttempt(payload)
require.Error(t, err)
require.ErrorContains(t, err, "cannot unmarshal")
}
func TestIdempotencyCodecRejectsTrailingJSON(t *testing.T) {
t.Parallel()
deliveryRecord := validDelivery(t)
payload, err := MarshalIdempotency(validIdempotencyRecord(t, deliveryRecord.Source, deliveryRecord.DeliveryID, deliveryRecord.IdempotencyKey))
require.NoError(t, err)
payload = append(payload, []byte(` {}`)...)
_, err = UnmarshalIdempotency(payload)
require.Error(t, err)
require.ErrorContains(t, err, "unexpected trailing JSON input")
}
func TestDeadLetterCodecRejectsUnknownField(t *testing.T) {
t.Parallel()
payload, err := MarshalDeadLetter(validDeadLetterEntry(t, validDelivery(t).DeliveryID))
require.NoError(t, err)
payload = append(payload[:len(payload)-1], []byte(`,"unexpected":"value"}`)...)
_, err = UnmarshalDeadLetter(payload)
require.Error(t, err)
require.ErrorContains(t, err, "unknown field")
}
var (
_ = attempt.Attempt{}
_ = deliverydomain.DeadLetterEntry{}
_ = idempotency.Record{}
)
@@ -0,0 +1,12 @@
// Package redisstate defines the frozen Redis keyspace, strict JSON records,
// and low-level mutation helpers used by future Mail Service Redis adapters.
package redisstate
import "errors"
var (
// ErrConflict reports that a Redis mutation could not be applied because
// one of the watched or newly created keys already existed or changed
// concurrently.
ErrConflict = errors.New("redis state conflict")
)
@@ -0,0 +1,201 @@
package redisstate
import (
"encoding/base64"
"time"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/domain/malformedcommand"
"galaxy/mail/internal/service/acceptgenericdelivery"
"github.com/stretchr/testify/require"
)
func validDelivery(t require.TestingT) deliverydomain.Delivery {
locale, err := common.ParseLocale("fr-fr")
require.NoError(t, err)
createdAt := time.Unix(1_775_121_700, 0).UTC()
updatedAt := createdAt.Add(2 * time.Minute)
sentAt := updatedAt.Add(15 * time.Second)
record := deliverydomain.Delivery{
DeliveryID: common.DeliveryID("delivery-123"),
ResendParentDeliveryID: common.DeliveryID("delivery-parent-001"),
Source: deliverydomain.SourceOperatorResend,
PayloadMode: deliverydomain.PayloadModeTemplate,
TemplateID: common.TemplateID("auth.login_code"),
Envelope: deliverydomain.Envelope{
To: []common.Email{common.Email("pilot@example.com")},
Cc: []common.Email{common.Email("copilot@example.com")},
Bcc: []common.Email{common.Email("ops@example.com")},
ReplyTo: []common.Email{common.Email("noreply@example.com")},
},
Content: deliverydomain.Content{
Subject: "Your login code",
TextBody: "Code: 123456",
HTMLBody: "<p>Code: <strong>123456</strong></p>",
},
Attachments: []common.AttachmentMetadata{
{Filename: "instructions.txt", ContentType: "text/plain; charset=utf-8", SizeBytes: 128},
},
Locale: locale,
TemplateVariables: map[string]any{
"code": "123456",
},
LocaleFallbackUsed: true,
IdempotencyKey: common.IdempotencyKey("operator:resend:delivery-123"),
Status: deliverydomain.StatusSent,
AttemptCount: 2,
LastAttemptStatus: attempt.StatusProviderAccepted,
ProviderSummary: "queued by provider",
CreatedAt: createdAt,
UpdatedAt: updatedAt,
SentAt: &sentAt,
}
require.NoError(t, record.Validate())
return record
}
func validScheduledAttempt(t require.TestingT, deliveryID common.DeliveryID) attempt.Attempt {
scheduledFor := time.Unix(1_775_121_820, 0).UTC()
record := attempt.Attempt{
DeliveryID: deliveryID,
AttemptNo: 1,
ScheduledFor: scheduledFor,
Status: attempt.StatusScheduled,
}
require.NoError(t, record.Validate())
return record
}
func validQueuedTemplateDelivery(t require.TestingT) deliverydomain.Delivery {
record := validDelivery(t)
record.DeliveryID = common.DeliveryID("delivery-queued")
record.ResendParentDeliveryID = ""
record.Source = deliverydomain.SourceNotification
record.Status = deliverydomain.StatusQueued
record.AttemptCount = 1
record.LastAttemptStatus = ""
record.ProviderSummary = ""
record.LocaleFallbackUsed = false
record.Content = deliverydomain.Content{}
record.CreatedAt = time.Unix(1_775_121_700, 0).UTC()
record.UpdatedAt = record.CreatedAt
record.SentAt = nil
record.SuppressedAt = nil
record.FailedAt = nil
record.DeadLetteredAt = nil
record.IdempotencyKey = common.IdempotencyKey("notification:delivery-queued")
require.NoError(t, record.Validate())
return record
}
func validTerminalAttempt(t require.TestingT, deliveryID common.DeliveryID) attempt.Attempt {
scheduledFor := time.Unix(1_775_121_820, 0).UTC()
startedAt := scheduledFor.Add(5 * time.Second)
finishedAt := startedAt.Add(2 * time.Second)
record := attempt.Attempt{
DeliveryID: deliveryID,
AttemptNo: 2,
ScheduledFor: scheduledFor,
StartedAt: &startedAt,
FinishedAt: &finishedAt,
Status: attempt.StatusProviderAccepted,
ProviderClassification: "accepted",
ProviderSummary: "queued by provider",
}
require.NoError(t, record.Validate())
return record
}
func validRenderFailedAttempt(t require.TestingT, deliveryID common.DeliveryID) attempt.Attempt {
record := validScheduledAttempt(t, deliveryID)
startedAt := record.ScheduledFor.Add(time.Second)
finishedAt := startedAt
record.StartedAt = &startedAt
record.FinishedAt = &finishedAt
record.Status = attempt.StatusRenderFailed
record.ProviderClassification = "missing_required_variable"
record.ProviderSummary = "missing required variables: player.name"
require.NoError(t, record.Validate())
return record
}
func validIdempotencyRecord(t require.TestingT, source deliverydomain.Source, deliveryID common.DeliveryID, key common.IdempotencyKey) idempotency.Record {
createdAt := time.Now().UTC().Truncate(time.Millisecond).Add(-time.Minute)
record := idempotency.Record{
Source: source,
IdempotencyKey: key,
DeliveryID: deliveryID,
RequestFingerprint: "sha256:abcdef123456",
CreatedAt: createdAt,
ExpiresAt: createdAt.Add(IdempotencyTTL),
}
require.NoError(t, record.Validate())
return record
}
func validDeadLetterEntry(t require.TestingT, deliveryID common.DeliveryID) deliverydomain.DeadLetterEntry {
entry := deliverydomain.DeadLetterEntry{
DeliveryID: deliveryID,
FinalAttemptNo: 3,
FailureClassification: "retry_exhausted",
ProviderSummary: "smtp timeout",
CreatedAt: time.Unix(1_775_122_000, 0).UTC(),
RecoveryHint: "check SMTP connectivity",
}
require.NoError(t, entry.Validate())
return entry
}
func validDeliveryPayload(t require.TestingT, deliveryID common.DeliveryID) acceptgenericdelivery.DeliveryPayload {
payload := acceptgenericdelivery.DeliveryPayload{
DeliveryID: deliveryID,
Attachments: []acceptgenericdelivery.AttachmentPayload{
{
Filename: "instructions.txt",
ContentType: "text/plain; charset=utf-8",
ContentBase64: base64.StdEncoding.EncodeToString([]byte("read me")),
SizeBytes: int64(len([]byte("read me"))),
},
},
}
require.NoError(t, payload.Validate())
return payload
}
func validMalformedCommandEntry(t require.TestingT) malformedcommand.Entry {
entry := malformedcommand.Entry{
StreamEntryID: "1775121700000-0",
DeliveryID: "mail-123",
Source: "notification",
IdempotencyKey: "notification:mail-123",
FailureCode: malformedcommand.FailureCodeInvalidPayload,
FailureMessage: "payload_json.subject is required",
RawFields: map[string]any{
"delivery_id": "mail-123",
"source": "notification",
"payload_mode": "rendered",
"idempotency_key": "notification:mail-123",
},
RecordedAt: time.Unix(1_775_121_700, 0).UTC(),
}
require.NoError(t, entry.Validate())
return entry
}
@@ -0,0 +1,148 @@
package redisstate
import (
"context"
"errors"
"fmt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/service/acceptgenericdelivery"
"github.com/redis/go-redis/v9"
)
// GenericAcceptanceStore provides the Redis-backed durable storage used by the
// generic-delivery acceptance use case.
type GenericAcceptanceStore struct {
client *redis.Client
writer *AtomicWriter
keys Keyspace
}
// NewGenericAcceptanceStore constructs one Redis-backed generic acceptance
// store.
func NewGenericAcceptanceStore(client *redis.Client) (*GenericAcceptanceStore, error) {
if client == nil {
return nil, errors.New("new generic acceptance store: nil redis client")
}
writer, err := NewAtomicWriter(client)
if err != nil {
return nil, fmt.Errorf("new generic acceptance store: %w", err)
}
return &GenericAcceptanceStore{
client: client,
writer: writer,
keys: Keyspace{},
}, nil
}
// CreateAcceptance stores one generic-delivery acceptance write set in Redis.
func (store *GenericAcceptanceStore) CreateAcceptance(ctx context.Context, input acceptgenericdelivery.CreateAcceptanceInput) error {
if store == nil || store.client == nil || store.writer == nil {
return errors.New("create generic acceptance: nil store")
}
if ctx == nil {
return errors.New("create generic acceptance: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("create generic acceptance: %w", err)
}
writerInput := CreateAcceptanceInput{
Delivery: input.Delivery,
FirstAttempt: &input.FirstAttempt,
Idempotency: &input.Idempotency,
}
if input.DeliveryPayload != nil {
writerInput.DeliveryPayload = input.DeliveryPayload
}
err := store.writer.CreateAcceptance(ctx, writerInput)
if errors.Is(err, ErrConflict) {
return fmt.Errorf("create generic acceptance: %w", acceptgenericdelivery.ErrConflict)
}
if err != nil {
return fmt.Errorf("create generic acceptance: %w", err)
}
return nil
}
// GetIdempotency loads one accepted idempotency scope from Redis.
func (store *GenericAcceptanceStore) GetIdempotency(ctx context.Context, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
if store == nil || store.client == nil {
return idempotency.Record{}, false, errors.New("get generic acceptance idempotency: nil store")
}
if ctx == nil {
return idempotency.Record{}, false, errors.New("get generic acceptance idempotency: nil context")
}
payload, err := store.client.Get(ctx, store.keys.Idempotency(source, key)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return idempotency.Record{}, false, nil
case err != nil:
return idempotency.Record{}, false, fmt.Errorf("get generic acceptance idempotency: %w", err)
}
record, err := UnmarshalIdempotency(payload)
if err != nil {
return idempotency.Record{}, false, fmt.Errorf("get generic acceptance idempotency: %w", err)
}
return record, true, nil
}
// GetDelivery loads one accepted delivery by its identifier.
func (store *GenericAcceptanceStore) GetDelivery(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
if store == nil || store.client == nil {
return deliverydomain.Delivery{}, false, errors.New("get generic acceptance delivery: nil store")
}
if ctx == nil {
return deliverydomain.Delivery{}, false, errors.New("get generic acceptance delivery: nil context")
}
payload, err := store.client.Get(ctx, store.keys.Delivery(deliveryID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return deliverydomain.Delivery{}, false, nil
case err != nil:
return deliverydomain.Delivery{}, false, fmt.Errorf("get generic acceptance delivery: %w", err)
}
record, err := UnmarshalDelivery(payload)
if err != nil {
return deliverydomain.Delivery{}, false, fmt.Errorf("get generic acceptance delivery: %w", err)
}
return record, true, nil
}
// GetDeliveryPayload loads one raw accepted attachment bundle by delivery id.
func (store *GenericAcceptanceStore) GetDeliveryPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
if store == nil || store.client == nil {
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get generic acceptance delivery payload: nil store")
}
if ctx == nil {
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get generic acceptance delivery payload: nil context")
}
payload, err := store.client.Get(ctx, store.keys.DeliveryPayload(deliveryID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return acceptgenericdelivery.DeliveryPayload{}, false, nil
case err != nil:
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get generic acceptance delivery payload: %w", err)
}
record, err := UnmarshalDeliveryPayload(payload)
if err != nil {
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get generic acceptance delivery payload: %w", err)
}
return record, true, nil
}
@@ -0,0 +1,145 @@
package redisstate
import (
"context"
"testing"
"time"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/service/acceptgenericdelivery"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestGenericAcceptanceStoreCreateAndReadRenderedDelivery(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewGenericAcceptanceStore(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.PayloadMode = deliverydomain.PayloadModeRendered
record.TemplateID = ""
record.TemplateVariables = nil
record.Locale = ""
record.LocaleFallbackUsed = false
record.Status = deliverydomain.StatusQueued
record.AttemptCount = 1
record.LastAttemptStatus = ""
record.ProviderSummary = ""
record.SentAt = nil
record.UpdatedAt = record.CreatedAt
require.NoError(t, record.Validate())
input := acceptgenericdelivery.CreateAcceptanceInput{
Delivery: record,
FirstAttempt: validScheduledAttempt(t, record.DeliveryID),
DeliveryPayload: ptr(validDeliveryPayload(t, record.DeliveryID)),
Idempotency: validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey),
}
require.NoError(t, store.CreateAcceptance(context.Background(), input))
storedDelivery, found, err := store.GetDelivery(context.Background(), record.DeliveryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, record, storedDelivery)
storedPayload, found, err := store.GetDeliveryPayload(context.Background(), record.DeliveryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, *input.DeliveryPayload, storedPayload)
}
func TestGenericAcceptanceStoreReturnsMissingPayload(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewGenericAcceptanceStore(client)
require.NoError(t, err)
payload, found, err := store.GetDeliveryPayload(context.Background(), common.DeliveryID("missing"))
require.NoError(t, err)
require.False(t, found)
require.Equal(t, acceptgenericdelivery.DeliveryPayload{}, payload)
}
func TestMalformedCommandStoreRecordIsIdempotent(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewMalformedCommandStore(client)
require.NoError(t, err)
entry := validMalformedCommandEntry(t)
require.NoError(t, store.Record(context.Background(), entry))
require.NoError(t, store.Record(context.Background(), entry))
storedEntry, found, err := store.Get(context.Background(), entry.StreamEntryID)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, entry, storedEntry)
indexCard, err := client.ZCard(context.Background(), Keyspace{}.MalformedCommandCreatedAtIndex()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, indexCard)
}
func TestMalformedCommandStoreAppliesRetention(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewMalformedCommandStore(client)
require.NoError(t, err)
entry := validMalformedCommandEntry(t)
require.NoError(t, store.Record(context.Background(), entry))
ttl := server.TTL(Keyspace{}.MalformedCommand(entry.StreamEntryID))
require.InDelta(t, DeadLetterTTL.Seconds(), ttl.Seconds(), 1)
}
func TestStreamOffsetStoreSaveAndLoad(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
store, err := NewStreamOffsetStore(client)
require.NoError(t, err)
require.NoError(t, store.Save(context.Background(), "mail:delivery_commands", "1775121700000-0"))
entryID, found, err := store.Load(context.Background(), "mail:delivery_commands")
require.NoError(t, err)
require.True(t, found)
require.Equal(t, "1775121700000-0", entryID)
payload, err := client.Get(context.Background(), Keyspace{}.StreamOffset("mail:delivery_commands")).Bytes()
require.NoError(t, err)
offset, err := UnmarshalStreamOffset(payload)
require.NoError(t, err)
require.Equal(t, "mail:delivery_commands", offset.Stream)
require.Equal(t, "1775121700000-0", offset.LastProcessedEntryID)
require.WithinDuration(t, time.Now().UTC(), offset.UpdatedAt, time.Second)
}
@@ -0,0 +1,118 @@
package redisstate
import (
"context"
"errors"
"fmt"
"strings"
"galaxy/mail/internal/domain/common"
"github.com/redis/go-redis/v9"
)
// CleanupReport describes the work done by IndexCleaner.
type CleanupReport struct {
// ScannedIndexes stores how many secondary index keys were inspected.
ScannedIndexes int
// ScannedMembers stores how many index members were examined.
ScannedMembers int
// RemovedMembers stores how many stale members were removed.
RemovedMembers int
}
// IndexCleaner removes stale delivery references from the Mail Service
// secondary indexes after primary delivery keys expire by TTL.
type IndexCleaner struct {
client *redis.Client
keyspace Keyspace
}
// NewIndexCleaner constructs one delivery-index cleanup helper.
func NewIndexCleaner(client *redis.Client) (*IndexCleaner, error) {
if client == nil {
return nil, errors.New("new redis index cleaner: nil client")
}
return &IndexCleaner{
client: client,
keyspace: Keyspace{},
}, nil
}
// CleanDeliveryIndexes scans every `mail:idx:*` key and removes members that
// no longer have a primary delivery record.
func (cleaner *IndexCleaner) CleanDeliveryIndexes(ctx context.Context) (CleanupReport, error) {
if cleaner == nil || cleaner.client == nil {
return CleanupReport{}, errors.New("clean delivery indexes in redis: nil cleaner")
}
if ctx == nil {
return CleanupReport{}, errors.New("clean delivery indexes in redis: nil context")
}
var (
report CleanupReport
cursor uint64
)
for {
keys, nextCursor, err := cleaner.client.Scan(ctx, cursor, cleaner.keyspace.SecondaryIndexPattern(), 0).Result()
if err != nil {
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: %w", err)
}
for _, key := range keys {
if key == cleaner.keyspace.MalformedCommandCreatedAtIndex() {
continue
}
report.ScannedIndexes++
members, err := cleaner.client.ZRange(ctx, key, 0, -1).Result()
if err != nil {
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: read index %q: %w", key, err)
}
report.ScannedMembers += len(members)
for _, member := range members {
remove, err := cleaner.shouldRemoveMember(ctx, member)
if err != nil {
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: inspect index %q member %q: %w", key, member, err)
}
if !remove {
continue
}
if err := cleaner.client.ZRem(ctx, key, member).Err(); err != nil {
return CleanupReport{}, fmt.Errorf("clean delivery indexes in redis: remove index %q member %q: %w", key, member, err)
}
report.RemovedMembers++
}
}
if nextCursor == 0 {
return report, nil
}
cursor = nextCursor
}
}
func (cleaner *IndexCleaner) shouldRemoveMember(ctx context.Context, member string) (bool, error) {
if strings.TrimSpace(member) == "" {
return true, nil
}
deliveryID := common.DeliveryID(member)
if err := deliveryID.Validate(); err != nil {
return true, nil
}
exists, err := cleaner.client.Exists(ctx, cleaner.keyspace.Delivery(deliveryID)).Result()
if err != nil {
return false, err
}
return exists == 0, nil
}
@@ -0,0 +1,112 @@
package redisstate
import (
"context"
"testing"
"time"
"galaxy/mail/internal/domain/attempt"
deliverydomain "galaxy/mail/internal/domain/delivery"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestIndexCleanerRemovesStaleMembersAfterDeliveryExpiry(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
writer, err := NewAtomicWriter(client)
require.NoError(t, err)
cleaner, err := NewIndexCleaner(client)
require.NoError(t, err)
record := validDelivery(t)
record.Source = deliverydomain.SourceNotification
record.ResendParentDeliveryID = ""
record.Status = deliverydomain.StatusQueued
record.SentAt = nil
record.LocaleFallbackUsed = false
record.UpdatedAt = record.CreatedAt.Add(time.Minute)
require.NoError(t, record.Validate())
input := CreateAcceptanceInput{
Delivery: record,
FirstAttempt: ptr(validScheduledAttempt(t, record.DeliveryID)),
Idempotency: ptr(validIdempotencyRecord(t, record.Source, record.DeliveryID, record.IdempotencyKey)),
}
require.NoError(t, writer.CreateAcceptance(context.Background(), input))
deadLetterEntry := validDeadLetterEntry(t, record.DeliveryID)
deadLetterPayload, err := MarshalDeadLetter(deadLetterEntry)
require.NoError(t, err)
require.NoError(t, client.Set(context.Background(), Keyspace{}.DeadLetter(record.DeliveryID), deadLetterPayload, DeadLetterTTL).Err())
server.FastForward(DeliveryTTL + time.Second)
require.False(t, server.Exists(Keyspace{}.Delivery(record.DeliveryID)))
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
require.True(t, server.Exists(Keyspace{}.DeadLetter(record.DeliveryID)))
report, err := cleaner.CleanDeliveryIndexes(context.Background())
require.NoError(t, err)
require.Positive(t, report.ScannedIndexes)
require.Positive(t, report.ScannedMembers)
require.Positive(t, report.RemovedMembers)
assertZCard := func(key string, want int64) {
t.Helper()
got, err := client.ZCard(context.Background(), key).Result()
require.NoError(t, err)
require.Equal(t, want, got)
}
assertZCard(Keyspace{}.CreatedAtIndex(), 0)
assertZCard(Keyspace{}.SourceIndex(record.Source), 0)
assertZCard(Keyspace{}.StatusIndex(record.Status), 0)
assertZCard(Keyspace{}.RecipientIndex(record.Envelope.To[0]), 0)
assertZCard(Keyspace{}.RecipientIndex(record.Envelope.Cc[0]), 0)
assertZCard(Keyspace{}.RecipientIndex(record.Envelope.Bcc[0]), 0)
assertZCard(Keyspace{}.TemplateIndex(record.TemplateID), 0)
assertZCard(Keyspace{}.IdempotencyIndex(record.Source, record.IdempotencyKey), 0)
require.True(t, server.Exists(Keyspace{}.Attempt(record.DeliveryID, input.FirstAttempt.AttemptNo)))
require.True(t, server.Exists(Keyspace{}.DeadLetter(record.DeliveryID)))
scheduleCard, err := client.ZCard(context.Background(), Keyspace{}.AttemptSchedule()).Result()
require.NoError(t, err)
require.EqualValues(t, 1, scheduleCard)
}
func TestIndexCleanerSkipsMalformedCommandIndex(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { require.NoError(t, client.Close()) })
cleaner, err := NewIndexCleaner(client)
require.NoError(t, err)
entry := validMalformedCommandEntry(t)
require.NoError(t, client.ZAdd(context.Background(), Keyspace{}.MalformedCommandCreatedAtIndex(), redis.Z{
Score: float64(entry.RecordedAt.UTC().UnixMilli()),
Member: entry.StreamEntryID,
}).Err())
report, err := cleaner.CleanDeliveryIndexes(context.Background())
require.NoError(t, err)
require.Zero(t, report.ScannedIndexes)
require.Zero(t, report.ScannedMembers)
require.Zero(t, report.RemovedMembers)
indexMembers, err := client.ZRange(context.Background(), Keyspace{}.MalformedCommandCreatedAtIndex(), 0, -1).Result()
require.NoError(t, err)
require.Equal(t, []string{entry.StreamEntryID}, indexMembers)
}
var _ = attempt.Attempt{}
@@ -0,0 +1,172 @@
package redisstate
import (
"encoding/base64"
"sort"
"strconv"
"time"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
)
const defaultPrefix = "mail:"
const (
// IdempotencyTTL is the frozen Redis retention for idempotency records.
IdempotencyTTL = 7 * 24 * time.Hour
// DeliveryTTL is the frozen Redis retention for accepted delivery records.
DeliveryTTL = 30 * 24 * time.Hour
// AttemptTTL is the frozen Redis retention for attempt records.
AttemptTTL = 90 * 24 * time.Hour
// DeadLetterTTL is the frozen Redis retention for dead-letter entries.
DeadLetterTTL = 90 * 24 * time.Hour
)
// Keyspace builds the frozen Mail Service Redis keys. All dynamic key
// segments are encoded with base64url so raw key structure does not depend on
// user-provided or caller-provided characters.
type Keyspace struct{}
// Delivery returns the primary Redis key for one mail_delivery record.
func (Keyspace) Delivery(deliveryID common.DeliveryID) string {
return defaultPrefix + "deliveries:" + encodeKeyComponent(deliveryID.String())
}
// Attempt returns the primary Redis key for one mail_attempt record.
func (Keyspace) Attempt(deliveryID common.DeliveryID, attemptNo int) string {
return defaultPrefix + "attempts:" + encodeKeyComponent(deliveryID.String()) + ":" + encodeKeyComponent(strconv.Itoa(attemptNo))
}
// Idempotency returns the primary Redis key for one mail_idempotency_record.
func (Keyspace) Idempotency(source deliverydomain.Source, key common.IdempotencyKey) string {
return defaultPrefix + "idempotency:" + encodeKeyComponent(string(source)) + ":" + encodeKeyComponent(key.String())
}
// DeadLetter returns the primary Redis key for one mail_dead_letter_entry.
func (Keyspace) DeadLetter(deliveryID common.DeliveryID) string {
return defaultPrefix + "dead_letters:" + encodeKeyComponent(deliveryID.String())
}
// DeliveryPayload returns the primary Redis key for one raw generic-delivery
// payload bundle.
func (Keyspace) DeliveryPayload(deliveryID common.DeliveryID) string {
return defaultPrefix + "delivery_payloads:" + encodeKeyComponent(deliveryID.String())
}
// MalformedCommand returns the primary Redis key for one operator-visible
// malformed async command record.
func (Keyspace) MalformedCommand(streamEntryID string) string {
return defaultPrefix + "malformed_commands:" + encodeKeyComponent(streamEntryID)
}
// StreamOffset returns the primary Redis key for one persisted stream-consumer
// offset.
func (Keyspace) StreamOffset(stream string) string {
return defaultPrefix + "stream_offsets:" + encodeKeyComponent(stream)
}
// DeliveryCommands returns the frozen async ingress Redis Stream key.
func (Keyspace) DeliveryCommands() string {
return defaultPrefix + "delivery_commands"
}
// AttemptSchedule returns the frozen attempt schedule sorted-set key.
func (Keyspace) AttemptSchedule() string {
return defaultPrefix + "attempt_schedule"
}
// RecipientIndex returns the secondary index key for one effective recipient.
func (Keyspace) RecipientIndex(email common.Email) string {
return defaultPrefix + "idx:recipient:" + encodeKeyComponent(email.String())
}
// StatusIndex returns the secondary index key for one delivery status.
func (Keyspace) StatusIndex(status deliverydomain.Status) string {
return defaultPrefix + "idx:status:" + encodeKeyComponent(string(status))
}
// SourceIndex returns the secondary index key for one delivery source.
func (Keyspace) SourceIndex(source deliverydomain.Source) string {
return defaultPrefix + "idx:source:" + encodeKeyComponent(string(source))
}
// TemplateIndex returns the secondary index key for one template id.
func (Keyspace) TemplateIndex(templateID common.TemplateID) string {
return defaultPrefix + "idx:template:" + encodeKeyComponent(templateID.String())
}
// IdempotencyIndex returns the secondary lookup key for one `(source,
// idempotency_key)` scope.
func (Keyspace) IdempotencyIndex(source deliverydomain.Source, key common.IdempotencyKey) string {
return defaultPrefix + "idx:idempotency:" + encodeKeyComponent(string(source)) + ":" + encodeKeyComponent(key.String())
}
// CreatedAtIndex returns the newest-first delivery ordering index key.
func (Keyspace) CreatedAtIndex() string {
return defaultPrefix + "idx:created_at"
}
// MalformedCommandCreatedAtIndex returns the newest-first malformed-command
// ordering index key.
func (Keyspace) MalformedCommandCreatedAtIndex() string {
return defaultPrefix + "idx:malformed_command:created_at"
}
// SecondaryIndexPattern returns the key-scan pattern that matches every
// delivery-level secondary index owned by Mail Service.
func (Keyspace) SecondaryIndexPattern() string {
return defaultPrefix + "idx:*"
}
// DeliveryIndexKeys returns the full set of secondary index keys that must
// reference record at creation time. Recipient indexing covers `to`, `cc`, and
// `bcc`, but intentionally excludes `reply_to`.
func (keyspace Keyspace) DeliveryIndexKeys(record deliverydomain.Delivery) []string {
keys := []string{
keyspace.StatusIndex(record.Status),
keyspace.SourceIndex(record.Source),
keyspace.IdempotencyIndex(record.Source, record.IdempotencyKey),
keyspace.CreatedAtIndex(),
}
if !record.TemplateID.IsZero() {
keys = append(keys, keyspace.TemplateIndex(record.TemplateID))
}
seen := make(map[string]struct{}, len(keys)+len(record.Envelope.To)+len(record.Envelope.Cc)+len(record.Envelope.Bcc))
for _, key := range keys {
seen[key] = struct{}{}
}
for _, group := range [][]common.Email{record.Envelope.To, record.Envelope.Cc, record.Envelope.Bcc} {
for _, email := range group {
seen[keyspace.RecipientIndex(email)] = struct{}{}
}
}
keys = keys[:0]
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// CreatedAtScore returns the frozen sorted-set score representation for
// delivery creation timestamps.
func CreatedAtScore(createdAt time.Time) float64 {
return float64(createdAt.UTC().UnixMilli())
}
// ScheduledForScore returns the frozen sorted-set score representation for
// attempt schedule timestamps.
func ScheduledForScore(scheduledFor time.Time) float64 {
return float64(scheduledFor.UTC().UnixMilli())
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}

Some files were not shown because too many files have changed in this diff Show More