// Package sendemailcode implements the public send-email-code use case. package sendemailcode import ( "context" "fmt" "reflect" "galaxy/authsession/internal/domain/challenge" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/ports" "galaxy/authsession/internal/service/shared" "galaxy/authsession/internal/telemetry" "go.uber.org/zap" ) // Input describes one public send-email-code request. type Input struct { // Email is the user-supplied e-mail address that should receive the login // code. Email string } // Result describes one public send-email-code response. type Result struct { // ChallengeID is the stable challenge identifier returned to the caller. ChallengeID string } // Service executes the public send-email-code use case. type Service struct { challengeStore ports.ChallengeStore userDirectory ports.UserDirectory idGenerator ports.IDGenerator codeGenerator ports.CodeGenerator codeHasher ports.CodeHasher mailSender ports.MailSender abuseProtector ports.SendEmailCodeAbuseProtector clock ports.Clock logger *zap.Logger telemetry *telemetry.Runtime } // New returns a send-email-code service wired to the required ports. func New( challengeStore ports.ChallengeStore, userDirectory ports.UserDirectory, idGenerator ports.IDGenerator, codeGenerator ports.CodeGenerator, codeHasher ports.CodeHasher, mailSender ports.MailSender, clock ports.Clock, ) (*Service, error) { return NewWithRuntime( challengeStore, userDirectory, idGenerator, codeGenerator, codeHasher, mailSender, nil, clock, nil, ) } // NewWithRuntime returns a send-email-code service wired to the required // ports plus the optional Stage-17 runtime collaborators. func NewWithRuntime( challengeStore ports.ChallengeStore, userDirectory ports.UserDirectory, idGenerator ports.IDGenerator, codeGenerator ports.CodeGenerator, codeHasher ports.CodeHasher, mailSender ports.MailSender, abuseProtector ports.SendEmailCodeAbuseProtector, clock ports.Clock, telemetryRuntime *telemetry.Runtime, ) (*Service, error) { return NewWithObservability( challengeStore, userDirectory, idGenerator, codeGenerator, codeHasher, mailSender, abuseProtector, clock, nil, telemetryRuntime, ) } // NewWithObservability returns a send-email-code service wired to the required // ports plus optional structured logging and telemetry dependencies. func NewWithObservability( challengeStore ports.ChallengeStore, userDirectory ports.UserDirectory, idGenerator ports.IDGenerator, codeGenerator ports.CodeGenerator, codeHasher ports.CodeHasher, mailSender ports.MailSender, abuseProtector ports.SendEmailCodeAbuseProtector, clock ports.Clock, logger *zap.Logger, telemetryRuntime *telemetry.Runtime, ) (*Service, error) { switch { case challengeStore == nil: return nil, fmt.Errorf("sendemailcode: challenge store must not be nil") case userDirectory == nil: return nil, fmt.Errorf("sendemailcode: user directory must not be nil") case idGenerator == nil: return nil, fmt.Errorf("sendemailcode: id generator must not be nil") case codeGenerator == nil: return nil, fmt.Errorf("sendemailcode: code generator must not be nil") case codeHasher == nil: return nil, fmt.Errorf("sendemailcode: code hasher must not be nil") case mailSender == nil: return nil, fmt.Errorf("sendemailcode: mail sender must not be nil") case clock == nil: return nil, fmt.Errorf("sendemailcode: clock must not be nil") default: return &Service{ challengeStore: challengeStore, userDirectory: userDirectory, idGenerator: idGenerator, codeGenerator: codeGenerator, codeHasher: codeHasher, mailSender: mailSender, abuseProtector: normalizeAbuseProtector(abuseProtector), clock: clock, logger: namedLogger(logger, "send_email_code"), telemetry: telemetryRuntime, }, nil } } // Execute creates a fresh challenge for every request, stores only the hashed // confirmation code, and records whether delivery was sent or intentionally // suppressed. func (s *Service) Execute(ctx context.Context, input Input) (result Result, err error) { logFields := []zap.Field{ zap.String("component", "service"), zap.String("use_case", "send_email_code"), } outcome := "" defer func() { if outcome != "" { logFields = append(logFields, zap.String("outcome", outcome)) } if result.ChallengeID != "" { logFields = append(logFields, zap.String("challenge_id", result.ChallengeID)) } shared.LogServiceOutcome(s.logger, ctx, "send email code completed", err, logFields...) }() email, err := shared.ParseEmail(input.Email) if err != nil { return Result{}, err } now := s.clock.Now().UTC() abuseResult, err := s.abuseProtector.CheckAndReserve(ctx, ports.SendEmailCodeAbuseInput{ Email: email, Now: now, }) if err != nil { return Result{}, shared.ServiceUnavailable(err) } if err := abuseResult.Validate(); err != nil { return Result{}, shared.InternalError(err) } challengeID, err := s.idGenerator.NewChallengeID() if err != nil { return Result{}, shared.ServiceUnavailable(err) } code, err := s.codeGenerator.Generate() if err != nil { return Result{}, shared.ServiceUnavailable(err) } codeHash, err := s.codeHasher.Hash(code) if err != nil { return Result{}, shared.ServiceUnavailable(err) } pendingStatus, pendingDeliveryState, err := ports.SendEmailCodeThrottleStatusToChallengeStatus(abuseResult.Outcome) if err != nil { 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), } if err := pending.Validate(); err != nil { return Result{}, shared.InternalError(err) } if err := s.challengeStore.Create(ctx, pending); err != nil { return Result{}, shared.ServiceUnavailable(err) } s.telemetry.RecordChallengeCreated(ctx) final := pending final.Attempts.Send = 1 final.Abuse.LastAttemptAt = &now if abuseResult.Outcome == ports.SendEmailCodeAbuseOutcomeThrottled { result, err = s.finishChallenge(ctx, pending, final) if err == nil { outcome = string(telemetry.SendEmailCodeOutcomeThrottled) s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeThrottled, telemetry.SendEmailCodeReasonThrottled) } return result, err } resolution, err := s.userDirectory.ResolveByEmail(ctx, email) if err != nil { return Result{}, shared.ServiceUnavailable(err) } if err := resolution.Validate(); err != nil { return Result{}, shared.InternalError(err) } s.telemetry.RecordUserDirectoryOutcome(ctx, "resolve_by_email", string(resolution.Kind)) switch resolution.Kind { case userresolution.KindBlocked: final.Status = challenge.StatusDeliverySuppressed final.DeliveryState = challenge.DeliverySuppressed result, err = s.finishChallenge(ctx, pending, final) if err == nil { outcome = string(telemetry.SendEmailCodeOutcomeSuppressed) s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSuppressed, telemetry.SendEmailCodeReasonBlocked) } return result, err default: deliveryResult, err := s.mailSender.SendLoginCode(ctx, ports.SendLoginCodeInput{ Email: email, Code: code, }) if err != nil { final.Status = challenge.StatusFailed final.DeliveryState = challenge.DeliveryFailed if _, persistErr := s.finishChallenge(ctx, pending, final); persistErr != nil { return Result{}, persistErr } outcome = string(telemetry.SendEmailCodeOutcomeFailed) s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeFailed, telemetry.SendEmailCodeReasonMailSender) return Result{}, shared.ServiceUnavailable(err) } if err := deliveryResult.Validate(); err != nil { return Result{}, shared.InternalError(err) } switch deliveryResult.Outcome { case ports.SendLoginCodeOutcomeSent: final.Status = challenge.StatusSent final.DeliveryState = challenge.DeliverySent result, err = s.finishChallenge(ctx, pending, final) if err == nil { outcome = string(telemetry.SendEmailCodeOutcomeSent) s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSent, "") } return result, err case ports.SendLoginCodeOutcomeSuppressed: final.Status = challenge.StatusDeliverySuppressed final.DeliveryState = challenge.DeliverySuppressed result, err = s.finishChallenge(ctx, pending, final) if err == nil { outcome = string(telemetry.SendEmailCodeOutcomeSuppressed) s.telemetry.RecordSendEmailCode(ctx, telemetry.SendEmailCodeOutcomeSuppressed, telemetry.SendEmailCodeReasonMailSender) } return result, err default: return Result{}, shared.InternalError(fmt.Errorf("sendemailcode: unsupported delivery outcome %q", deliveryResult.Outcome)) } } } func (s *Service) finishChallenge(ctx context.Context, pending challenge.Challenge, final challenge.Challenge) (Result, error) { if err := final.Validate(); err != nil { return Result{}, shared.InternalError(err) } if err := s.challengeStore.CompareAndSwap(ctx, pending, final); err != nil { return Result{}, shared.ServiceUnavailable(err) } return Result{ChallengeID: final.ID.String()}, nil } func normalizeAbuseProtector(protector ports.SendEmailCodeAbuseProtector) ports.SendEmailCodeAbuseProtector { if protector == nil { return allowAllSendEmailCodeAbuseProtector{} } value := reflect.ValueOf(protector) switch value.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: if value.IsNil() { return allowAllSendEmailCodeAbuseProtector{} } } return protector } type allowAllSendEmailCodeAbuseProtector struct{} func (allowAllSendEmailCodeAbuseProtector) CheckAndReserve(_ context.Context, input ports.SendEmailCodeAbuseInput) (ports.SendEmailCodeAbuseResult, error) { if err := input.Validate(); err != nil { return ports.SendEmailCodeAbuseResult{}, err } return ports.SendEmailCodeAbuseResult{ Outcome: ports.SendEmailCodeAbuseOutcomeAllowed, }, nil } func namedLogger(logger *zap.Logger, name string) *zap.Logger { if logger == nil { logger = zap.NewNop() } return logger.Named(name) }