feat: mail service
This commit is contained in:
+25
-7
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use (
|
||||
./game
|
||||
./gateway
|
||||
./integration
|
||||
./mail
|
||||
./pkg/calc
|
||||
./pkg/connector
|
||||
./pkg/error
|
||||
|
||||
+9
-3
@@ -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
@@ -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+".")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user