package emailtemplates import ( "Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/ports" "context" "errors" "fmt" "strings" "sync" "time" "github.com/jackc/pgx/v5" ) var ( errSlugRequired = errors.New("slug is required") errInvalidSlug = errors.New("slug must start with a letter and contain only lowercase letters, numbers, and underscores") errInvalidStatus = fmt.Errorf("status must be one of %s, %s", domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive) ) const cacheTTL = 2 * time.Minute type cacheEntry struct { template domain.EmailTemplate expiresAt time.Time } type Service struct { store ports.EmailTemplateStore mu sync.RWMutex cache map[string]cacheEntry } func NewService(store ports.EmailTemplateStore) *Service { return &Service{ store: store, cache: make(map[string]cacheEntry), } } func (s *Service) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) { slug, err := normalizeSlug(input.Slug) if err != nil { return domain.EmailTemplate{}, err } input.Slug = slug input.Name = strings.TrimSpace(input.Name) input.Subject = strings.TrimSpace(input.Subject) input.BodyText = strings.TrimSpace(input.BodyText) input.BodyHTML = strings.TrimSpace(input.BodyHTML) input.Variables = normalizeVariables(input.Variables) if input.Name == "" { return domain.EmailTemplate{}, fmt.Errorf("name is required") } if input.Subject == "" { return domain.EmailTemplate{}, fmt.Errorf("subject is required") } if input.BodyText == "" { return domain.EmailTemplate{}, fmt.Errorf("body_text is required") } if input.BodyHTML == "" { return domain.EmailTemplate{}, fmt.Errorf("body_html is required") } status, err := normalizeStatus(input.Status) if err != nil { return domain.EmailTemplate{}, err } input.Status = &status if err := s.validateTemplateSyntax(input.Subject, input.BodyText, input.BodyHTML); err != nil { return domain.EmailTemplate{}, err } tmpl, err := s.store.CreateEmailTemplate(ctx, input) if err != nil { return domain.EmailTemplate{}, err } s.invalidateCache(tmpl.Slug) return tmpl, nil } func (s *Service) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) { if id <= 0 { return domain.EmailTemplate{}, fmt.Errorf("invalid email template id") } if input.Name != nil { trimmed := strings.TrimSpace(*input.Name) if trimmed == "" { return domain.EmailTemplate{}, fmt.Errorf("name cannot be empty") } input.Name = &trimmed } if input.Subject != nil { trimmed := strings.TrimSpace(*input.Subject) if trimmed == "" { return domain.EmailTemplate{}, fmt.Errorf("subject cannot be empty") } input.Subject = &trimmed } if input.BodyText != nil { trimmed := strings.TrimSpace(*input.BodyText) if trimmed == "" { return domain.EmailTemplate{}, fmt.Errorf("body_text cannot be empty") } input.BodyText = &trimmed } if input.BodyHTML != nil { trimmed := strings.TrimSpace(*input.BodyHTML) if trimmed == "" { return domain.EmailTemplate{}, fmt.Errorf("body_html cannot be empty") } input.BodyHTML = &trimmed } if input.Variables != nil { input.Variables = normalizeVariables(input.Variables) } if input.Status != nil { status, err := normalizeStatus(input.Status) if err != nil { return domain.EmailTemplate{}, err } input.Status = &status } current, err := s.store.GetEmailTemplateByID(ctx, id, true) if err != nil { return domain.EmailTemplate{}, err } subject := current.Subject if input.Subject != nil { subject = *input.Subject } bodyText := current.BodyText if input.BodyText != nil { bodyText = *input.BodyText } bodyHTML := current.BodyHTML if input.BodyHTML != nil { bodyHTML = *input.BodyHTML } if err := s.validateTemplateSyntax(subject, bodyText, bodyHTML); err != nil { return domain.EmailTemplate{}, err } tmpl, err := s.store.UpdateEmailTemplate(ctx, id, input) if err != nil { return domain.EmailTemplate{}, err } s.invalidateCache(tmpl.Slug) return tmpl, nil } func (s *Service) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) { if id <= 0 { return domain.EmailTemplate{}, fmt.Errorf("invalid email template id") } return s.store.GetEmailTemplateByID(ctx, id, includeInactive) } func (s *Service) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) { normalized, err := normalizeSlug(slug) if err != nil { return domain.EmailTemplate{}, err } return s.store.GetEmailTemplateBySlug(ctx, normalized, includeInactive) } func (s *Service) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) { if status != nil { normalized, err := normalizeStatus(status) if err != nil { return nil, 0, err } status = &normalized } if query != nil { trimmed := strings.TrimSpace(*query) if trimmed == "" { query = nil } else { query = &trimmed } } if limit <= 0 { limit = 20 } if limit > 200 { limit = 200 } if offset < 0 { offset = 0 } return s.store.ListEmailTemplates(ctx, status, query, limit, offset) } func (s *Service) DeleteEmailTemplate(ctx context.Context, id int64) error { if id <= 0 { return fmt.Errorf("invalid email template id") } tmpl, err := s.store.GetEmailTemplateByID(ctx, id, true) if err != nil { return err } if tmpl.IsSystem { return fmt.Errorf("system email templates cannot be deleted") } if err := s.store.DeleteEmailTemplate(ctx, id); err != nil { return err } s.invalidateCache(tmpl.Slug) return nil } func (s *Service) PreviewEmailTemplate(ctx context.Context, input domain.PreviewEmailTemplateInput) (domain.RenderedEmail, error) { slug, err := normalizeSlug(input.Slug) if err != nil { return domain.RenderedEmail{}, err } tmpl, err := s.resolveTemplate(ctx, slug, true) if err != nil { return domain.RenderedEmail{}, err } return renderTemplateFields(tmpl, input.Variables) } func (s *Service) Render(ctx context.Context, slug string, data map[string]any) (domain.RenderedEmail, error) { normalized, err := normalizeSlug(slug) if err != nil { return domain.RenderedEmail{}, err } tmpl, err := s.resolveTemplate(ctx, normalized, false) if err != nil { return domain.RenderedEmail{}, err } return renderTemplateFields(tmpl, data) } func (s *Service) resolveTemplate(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) { if !includeInactive { if tmpl, ok := s.getCached(slug); ok { return tmpl, nil } } tmpl, err := s.store.GetEmailTemplateBySlug(ctx, slug, includeInactive) if err == nil { if !includeInactive && tmpl.Status == domain.EmailTemplateStatusActive { s.setCached(slug, tmpl) } return tmpl, nil } if !errors.Is(err, pgx.ErrNoRows) { return domain.EmailTemplate{}, err } fallback, ok := defaultTemplate(slug) if !ok { return domain.EmailTemplate{}, fmt.Errorf("email template not found: %s", slug) } return fallback, nil } func (s *Service) validateTemplateSyntax(subject, bodyText, bodyHTML string) error { if _, err := newTextTemplate("validate-subject", subject); err != nil { return fmt.Errorf("invalid subject template: %w", err) } if _, err := newTextTemplate("validate-text", bodyText); err != nil { return fmt.Errorf("invalid body_text template: %w", err) } if _, err := newHTMLTemplate("validate-html", bodyHTML); err != nil { return fmt.Errorf("invalid body_html template: %w", err) } return nil } func (s *Service) getCached(slug string) (domain.EmailTemplate, bool) { s.mu.RLock() defer s.mu.RUnlock() entry, ok := s.cache[slug] if !ok || time.Now().After(entry.expiresAt) { return domain.EmailTemplate{}, false } return entry.template, true } func (s *Service) setCached(slug string, tmpl domain.EmailTemplate) { s.mu.Lock() defer s.mu.Unlock() s.cache[slug] = cacheEntry{ template: tmpl, expiresAt: time.Now().Add(cacheTTL), } } func (s *Service) invalidateCache(slug string) { s.mu.Lock() defer s.mu.Unlock() delete(s.cache, slug) }