package service import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "time" "wucher/internal/shared/pkg/uuidv7" "wucher/internal/queue" ) type MockEmailSender struct { dir string } type MockEmailRecord struct { StoredAt time.Time `json:"stored_at"` Provider string `json:"provider"` JobID string `json:"job_id"` MessageType string `json:"message_type"` To string `json:"to"` CC []string `json:"cc,omitempty"` BCC []string `json:"bcc,omitempty"` ReplyTo []string `json:"reply_to,omitempty"` Subject string `json:"subject"` TextBody string `json:"text_body,omitempty"` HTMLBody string `json:"html_body,omitempty"` Tags map[string]string `json:"tags,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` TraceID string `json:"trace_id,omitempty"` RequestID string `json:"request_id,omitempty"` } func NewMockEmailSender(dir string) *MockEmailSender { return &MockEmailSender{dir: strings.TrimSpace(dir)} } func (s *MockEmailSender) Configured() bool { return s != nil && strings.TrimSpace(s.dir) != "" } func (s *MockEmailSender) Send(ctx context.Context, job queue.EmailJob) (EmailDeliveryResult, error) { if !s.Configured() { return EmailDeliveryResult{}, errors.New("mock email sender is not configured") } job = job.Canonical() if err := job.Validate(); err != nil { return EmailDeliveryResult{}, err } select { case <-ctx.Done(): return EmailDeliveryResult{}, ctx.Err() default: } if err := os.MkdirAll(s.dir, 0o755); err != nil { return EmailDeliveryResult{}, err } jobID := strings.TrimSpace(job.JobID) if jobID == "" { var err error jobID, err = uuidv7.String(uuidv7.MustNew()) if err != nil { return EmailDeliveryResult{}, err } } filename := time.Now().UTC().Format("20060102T150405.000000000Z") + "_" + sanitizeFileName(job.NormalizedType()) + "_" + sanitizeFileName(jobID) + ".json" path := filepath.Join(s.dir, filename) record := MockEmailRecord{ StoredAt: time.Now().UTC(), Provider: "mock", JobID: jobID, MessageType: job.NormalizedType(), To: job.To, CC: append([]string(nil), job.CC...), BCC: append([]string(nil), job.BCC...), ReplyTo: append([]string(nil), job.ReplyTo...), Subject: job.Subject, TextBody: job.TextBodyValue(), HTMLBody: job.HTMLBody, Tags: cloneStringMap(job.Tags), Metadata: cloneStringMap(job.Metadata), TraceID: job.TraceID, RequestID: job.RequestID, } payload, err := json.MarshalIndent(record, "", " ") if err != nil { return EmailDeliveryResult{}, err } payload = append(payload, '\n') if err := os.WriteFile(path, payload, 0o600); err != nil { return EmailDeliveryResult{}, err } return EmailDeliveryResult{ Provider: "mock", ProviderMessageID: filename, RequestID: path, }, nil } func sanitizeFileName(value string) string { value = strings.TrimSpace(value) if value == "" { return "email" } var b strings.Builder b.Grow(len(value)) for _, r := range value { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= 'A' && r <= 'Z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '-', r == '_': b.WriteRune(r) default: b.WriteRune('_') } } return b.String() } func cloneStringMap(values map[string]string) map[string]string { if len(values) == 0 { return nil } out := make(map[string]string, len(values)) for key, value := range values { out[key] = value } return out }