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,305 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/mail"
"net/url"
"sort"
"strings"
"time"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
"wucher/internal/config"
"wucher/internal/queue"
"wucher/internal/resilience"
"wucher/internal/shared/pkg/metrics"
)
const utf8Charset = "UTF-8"
type sesAPI interface {
SendEmail(ctx context.Context, params *sesv2.SendEmailInput, optFns ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error)
}
type SESSender struct {
cfg config.SESConfig
api sesAPI
executor *resilience.Executor
component string
}
func NewSESSender(ctx context.Context, cfg config.SESConfig) (*SESSender, error) {
api, err := newSESAPI(ctx, cfg)
if err != nil {
return nil, err
}
return &SESSender{
cfg: cfg,
api: api,
component: "worker",
}, nil
}
func NewSESSenderWithAPI(cfg config.SESConfig, api sesAPI) *SESSender {
return &SESSender{
cfg: cfg,
api: api,
component: "worker",
}
}
func (s *SESSender) WithExecutor(executor *resilience.Executor) *SESSender {
if s == nil {
return nil
}
s.executor = executor
return s
}
func (s *SESSender) WithMetricsComponent(component string) *SESSender {
if s == nil {
return nil
}
component = strings.TrimSpace(component)
if component == "" {
return s
}
s.component = component
return s
}
func (s *SESSender) Configured() bool {
return s != nil && strings.TrimSpace(s.cfg.Region) != "" && strings.TrimSpace(s.cfg.From) != ""
}
func (s *SESSender) Send(ctx context.Context, job queue.EmailJob) (EmailDeliveryResult, error) {
return resilience.Do(ctx, s.executor, func(ctx context.Context) (EmailDeliveryResult, error) {
if !s.Configured() {
return EmailDeliveryResult{}, errors.New("ses is not configured")
}
if s.api == nil {
return EmailDeliveryResult{}, errors.New("ses client is required")
}
job = job.Canonical()
if err := job.Validate(); err != nil {
return EmailDeliveryResult{}, err
}
input, err := s.buildSendInput(job)
if err != nil {
return EmailDeliveryResult{}, err
}
sendCtx := ctx
cancel := func() {}
if s.cfg.SendTimeout > 0 {
sendCtx, cancel = context.WithTimeout(ctx, s.cfg.SendTimeout)
}
defer cancel()
started := time.Now()
output, err := s.api.SendEmail(sendCtx, input)
metrics.ObserveAWSRequest(s.component, "ses", "SendEmail", err, started)
metrics.ObserveEmailSend(s.component, "ses", err, started)
if err != nil {
return EmailDeliveryResult{}, err
}
result := EmailDeliveryResult{
Provider: "ses",
ProviderMessageID: awsv2.ToString(output.MessageId),
}
if requestID, ok := awsmiddleware.GetRequestIDMetadata(output.ResultMetadata); ok {
result.RequestID = requestID
}
return result, nil
})
}
func (s *SESSender) buildSendInput(job queue.EmailJob) (*sesv2.SendEmailInput, error) {
from, err := mail.ParseAddress(strings.TrimSpace(s.cfg.From))
if err != nil {
return nil, err
}
to, err := normalizeEmailAddresses([]string{job.To})
if err != nil {
return nil, err
}
cc, err := normalizeEmailAddresses(job.CC)
if err != nil {
return nil, err
}
bcc, err := normalizeEmailAddresses(job.BCC)
if err != nil {
return nil, err
}
replyTo, err := normalizeEmailAddresses(job.ReplyTo)
if err != nil {
return nil, err
}
input := &sesv2.SendEmailInput{
FromEmailAddress: awsv2.String(from.String()),
Destination: &sestypes.Destination{
ToAddresses: to,
CcAddresses: cc,
BccAddresses: bcc,
},
ReplyToAddresses: replyTo,
EmailTags: buildSESTags(job.Tags),
}
if cfgSet := strings.TrimSpace(s.cfg.ConfigurationSetName); cfgSet != "" {
input.ConfigurationSetName = awsv2.String(cfgSet)
}
content, err := buildSESContent(from.String(), to, cc, replyTo, job)
if err != nil {
return nil, err
}
input.Content = content
return input, nil
}
func buildSESContent(from string, to, cc, replyTo []string, job queue.EmailJob) (*sestypes.EmailContent, error) {
if len(job.Attachments) > 0 {
return buildSESRawContent(from, to, cc, replyTo, job)
}
if strings.TrimSpace(job.TemplateName) != "" && strings.TrimSpace(job.TextBodyValue()) == "" && strings.TrimSpace(job.HTMLBody) == "" {
templateData := "{}"
if len(job.TemplateData) > 0 {
encoded, err := json.Marshal(job.TemplateData)
if err != nil {
return nil, err
}
templateData = string(encoded)
}
return &sestypes.EmailContent{
Template: &sestypes.Template{
TemplateName: awsv2.String(job.TemplateName),
TemplateData: awsv2.String(templateData),
},
}, nil
}
body := &sestypes.Body{}
if textBody := strings.TrimSpace(job.TextBodyValue()); textBody != "" {
body.Text = &sestypes.Content{
Data: awsv2.String(textBody),
Charset: awsv2.String(utf8Charset),
}
}
if htmlBody := strings.TrimSpace(job.HTMLBody); htmlBody != "" {
body.Html = &sestypes.Content{
Data: awsv2.String(htmlBody),
Charset: awsv2.String(utf8Charset),
}
}
return &sestypes.EmailContent{
Simple: &sestypes.Message{
Subject: &sestypes.Content{
Data: awsv2.String(sanitizeSubject(job.Subject)),
Charset: awsv2.String(utf8Charset),
},
Body: body,
},
}, nil
}
func normalizeEmailAddresses(addresses []string) ([]string, error) {
if len(addresses) == 0 {
return nil, nil
}
out := make([]string, 0, len(addresses))
for _, address := range addresses {
address = strings.TrimSpace(address)
if address == "" {
continue
}
parsed, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
out = append(out, parsed.Address)
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func buildSESTags(tags map[string]string) []sestypes.MessageTag {
if len(tags) == 0 {
return nil
}
keys := make([]string, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]sestypes.MessageTag, 0, len(keys))
for _, key := range keys {
value := strings.TrimSpace(tags[key])
if strings.TrimSpace(key) == "" || value == "" {
continue
}
out = append(out, sestypes.MessageTag{
Name: awsv2.String(strings.TrimSpace(key)),
Value: awsv2.String(value),
})
}
if len(out) == 0 {
return nil
}
return out
}
func sanitizeSubject(subject string) string {
subject = strings.TrimSpace(subject)
subject = strings.ReplaceAll(subject, "\r", "")
subject = strings.ReplaceAll(subject, "\n", "")
return subject
}
func newSESAPI(ctx context.Context, cfg config.SESConfig) (sesAPI, error) {
if err := validateSESConfig(cfg); err != nil {
return nil, err
}
loadOptions := make([]func(*awsconfig.LoadOptions) error, 0, 1)
if strings.TrimSpace(cfg.Region) != "" {
loadOptions = append(loadOptions, awsconfig.WithRegion(strings.TrimSpace(cfg.Region)))
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...)
if err != nil {
return nil, err
}
return sesv2.NewFromConfig(awsCfg, func(options *sesv2.Options) {
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
options.EndpointResolver = sesv2.EndpointResolverFromURL(endpoint)
}
}), nil
}
func validateSESConfig(cfg config.SESConfig) error {
if strings.TrimSpace(cfg.Region) == "" {
return errors.New("aws region is required")
}
if strings.TrimSpace(cfg.From) == "" {
return errors.New("ses from address is required")
}
if endpoint := strings.TrimSpace(cfg.Endpoint); endpoint != "" {
parsed, err := url.Parse(endpoint)
if err != nil {
return err
}
if parsed.Scheme != "https" && !cfg.AllowInsecureEndpoint {
return errors.New("insecure ses endpoint is disabled")
}
}
return nil
}