init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
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)
}
}