package service import ( "context" "errors" "log/slog" "net" "strings" "github.com/aws/smithy-go" "wucher/internal/queue" ) type EmailDeliveryHandler struct { sender EmailSender logger *slog.Logger } func NewEmailDeliveryHandler(sender EmailSender, logger *slog.Logger) *EmailDeliveryHandler { if logger == nil { logger = slog.Default() } return &EmailDeliveryHandler{ sender: sender, logger: logger, } } func (h *EmailDeliveryHandler) Handle(ctx context.Context, envelope *queue.Envelope) error { if h.sender == nil { return queue.Permanent(errors.New("email sender is required")) } if envelope == nil { return queue.Permanent(errors.New("email envelope is required")) } if err := envelope.Payload.Validate(); err != nil { return queue.Permanent(err) } job := envelope.Payload shouldSkipSend := strings.EqualFold(strings.TrimSpace(job.Metadata["skip_send"]), "true") // Defense-in-depth: register verification emails should never be delivered. if !shouldSkipSend && strings.EqualFold(strings.TrimSpace(job.MessageType), "email_verify") && strings.EqualFold(strings.TrimSpace(job.Tags["source"]), "register") { shouldSkipSend = true } if shouldSkipSend { h.logger.Info("email delivery skipped", slog.String("message_id", envelope.MessageID), slog.String("job_type", job.NormalizedType()), slog.String("recipient_hash", job.RecipientHash()), slog.String("correlation_id", envelope.CorrelationID), ) return nil } result, err := h.sender.Send(ctx, job) if err != nil { classified := classifyDeliveryError(err) h.logger.Error("email delivery failed", slog.String("message_id", envelope.MessageID), slog.String("job_type", job.NormalizedType()), slog.String("recipient_hash", job.RecipientHash()), slog.String("correlation_id", envelope.CorrelationID), slog.Any("error", err), ) return classified } h.logger.Info("email delivered", slog.String("message_id", envelope.MessageID), slog.String("job_type", job.NormalizedType()), slog.String("recipient_hash", job.RecipientHash()), slog.String("correlation_id", envelope.CorrelationID), slog.String("provider", result.Provider), slog.String("provider_message_id", result.ProviderMessageID), slog.String("provider_request_id", result.RequestID), ) return nil } func classifyDeliveryError(err error) error { if err == nil { return nil } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return queue.Transient(err) } var netErr net.Error if errors.As(err, &netErr) { return queue.Transient(err) } var apiErr smithy.APIError if errors.As(err, &apiErr) { code := strings.ToLower(strings.TrimSpace(apiErr.ErrorCode())) switch code { case "throttling", "throttlingexception", "toomanyrequestsexception", "serviceunavailableexception", "internalserviceerror", "sendingpausedexception": return queue.Transient(err) case "message_rejected", "messagerejected", "badrequestexception", "mailfromdomainnotverifiedexception", "configurationsetdoesnotexistexception", "notfoundexception": return queue.Permanent(err) } } message := strings.ToLower(strings.TrimSpace(err.Error())) switch { case strings.Contains(message, "recipient is required"): return queue.Permanent(err) case strings.Contains(message, "subject is required"): return queue.Permanent(err) case strings.Contains(message, "body is required"): return queue.Permanent(err) case strings.Contains(message, "invalid email job"): return queue.Permanent(err) case strings.Contains(message, "ses is not configured"): return queue.Permanent(err) default: return queue.Transient(err) } }