feat: mail service
This commit is contained in:
@@ -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{
|
||||
@@ -191,13 +197,14 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
|
||||
return Result{}, shared.InternalError(err)
|
||||
}
|
||||
pending := challenge.Challenge{
|
||||
ID: challengeID,
|
||||
Email: email,
|
||||
CodeHash: codeHash,
|
||||
Status: pendingStatus,
|
||||
DeliveryState: pendingDeliveryState,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(challenge.InitialTTL),
|
||||
ID: challengeID,
|
||||
Email: email,
|
||||
CodeHash: codeHash,
|
||||
PreferredLanguage: preferredLanguage,
|
||||
Status: pendingStatus,
|
||||
DeliveryState: pendingDeliveryState,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(challenge.InitialTTL),
|
||||
}
|
||||
if err := pending.Validate(); err != nil {
|
||||
return Result{}, shared.InternalError(err)
|
||||
@@ -240,8 +247,10 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
|
||||
return result, err
|
||||
default:
|
||||
deliveryResult, err := s.mailSender.SendLoginCode(ctx, ports.SendLoginCodeInput{
|
||||
Email: email,
|
||||
Code: code,
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user