120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
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)
|
|
}
|
|
}
|