feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -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{
"email": input.Email.String(),
"code": input.Code,
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()
@@ -146,12 +147,15 @@ func TestRESTClientPreservesNormalizedEmailAndCodeExactly(t *testing.T) {
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
result, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("Pilot+Alias@Example.com"),
Code: "123456",
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) {
@@ -311,8 +315,10 @@ func TestRESTClientContextAndValidation(t *testing.T) {
name: "invalid email",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email(" bad@example.com "),
Code: "123456",
Email: common.Email(" bad@example.com "),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: "en",
})
return err
},
@@ -321,8 +327,34 @@ func TestRESTClientContextAndValidation(t *testing.T) {
name: "invalid code",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: " 123456 ",
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
},
@@ -340,10 +372,11 @@ func TestRESTClientContextAndValidation(t *testing.T) {
}
type capturedRequest struct {
Method string
Path string
ContentType string
Body string
Method string
Path string
ContentType string
IdempotencyKey string
Body string
}
func captureRequest(t *testing.T, request *http.Request) capturedRequest {
@@ -353,10 +386,11 @@ func captureRequest(t *testing.T, request *http.Request) capturedRequest {
require.NoError(t, err)
return capturedRequest{
Method: request.Method,
Path: request.URL.Path,
ContentType: request.Header.Get("Content-Type"),
Body: strings.TrimSpace(string(body)),
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.
@@ -192,7 +192,9 @@ func TestStubSenderSendLoginCodeInvalidInput(t *testing.T) {
func validInput() ports.SendLoginCodeInput {
return ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: "654321",
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
}
}