// Package mail provides runtime mail-delivery adapters for the auth/session // service. package mail import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "galaxy/authsession/internal/ports" ) const sendLoginCodePath = "/api/v1/internal/login-code-deliveries" // Config configures one HTTP-based mail-delivery client. type Config struct { // BaseURL is the absolute base URL of the internal mail-service HTTP API. BaseURL string // RequestTimeout bounds each outbound mail-service request. RequestTimeout time.Duration } // RESTClient implements ports.MailSender over the frozen internal REST mail // contract. type RESTClient struct { baseURL string requestTimeout time.Duration httpClient *http.Client } // NewRESTClient constructs a REST-backed MailSender adapter from cfg. func NewRESTClient(cfg Config) (*RESTClient, error) { transport := http.DefaultTransport.(*http.Transport).Clone() return newRESTClient(cfg, &http.Client{Transport: transport}) } func newRESTClient(cfg Config, httpClient *http.Client) (*RESTClient, error) { switch { case strings.TrimSpace(cfg.BaseURL) == "": return nil, errors.New("new mail service REST client: base URL must not be empty") case cfg.RequestTimeout <= 0: return nil, errors.New("new mail service REST client: request timeout must be positive") case httpClient == nil: return nil, errors.New("new mail service REST client: http client must not be nil") } parsedBaseURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")) if err != nil { return nil, fmt.Errorf("new mail service REST client: parse base URL: %w", err) } if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" { return nil, errors.New("new mail service REST client: base URL must be absolute") } return &RESTClient{ baseURL: parsedBaseURL.String(), requestTimeout: cfg.RequestTimeout, httpClient: httpClient, }, nil } // Close releases idle HTTP connections owned by the client transport. func (c *RESTClient) Close() error { if c == nil || c.httpClient == nil { return nil } type idleCloser interface { CloseIdleConnections() } if transport, ok := c.httpClient.Transport.(idleCloser); ok { transport.CloseIdleConnections() } return nil } // SendLoginCode submits one delivery request to the internal mail service // without retrying transport or upstream failures. func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCodeInput) (ports.SendLoginCodeResult, error) { if err := validateRESTContext(ctx, "send login code"); err != nil { return ports.SendLoginCodeResult{}, err } if err := input.Validate(); err != nil { return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err) } 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 } if statusCode != http.StatusOK { return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: unexpected HTTP status %d", statusCode) } var response struct { Outcome ports.SendLoginCodeOutcome `json:"outcome"` } if err := decodeJSONPayload(payload, &response); err != nil { return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err) } result := ports.SendLoginCodeResult{Outcome: response.Outcome} if err := result.Validate(); err != nil { return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err) } return result, nil } 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) } attemptCtx, cancel := context.WithTimeout(ctx, c.requestTimeout) defer cancel() request, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.baseURL+sendLoginCodePath, bytes.NewReader(bodyBytes)) if err != nil { 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 { return nil, 0, fmt.Errorf("%s: %w", operation, err) } defer response.Body.Close() payload, err := io.ReadAll(response.Body) if err != nil { return nil, 0, fmt.Errorf("%s: read response body: %w", operation, err) } return payload, response.StatusCode, nil } func decodeJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return fmt.Errorf("decode response body: %w", err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("decode response body: unexpected trailing JSON input") } return fmt.Errorf("decode response body: %w", err) } return nil } func validateRESTContext(ctx context.Context, operation string) error { if ctx == nil { return fmt.Errorf("%s: nil context", operation) } if err := ctx.Err(); err != nil { return fmt.Errorf("%s: %w", operation, err) } return nil } var _ ports.MailSender = (*RESTClient)(nil)