package postcard import ( "context" "fmt" "log/slog" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" ) // Message is the Send contract for plugin callers. type Message struct { Template string To []string Cc []string Bcc []string ReplyTo string Vars map[string]any Subject string } // Mailer sends a registered template through the configured driver. type Mailer interface { Send(ctx context.Context, msg Message) error } // Options configure an app-scoped mailer. type Options struct { From string CSS string BrandCSS string } type mailer struct { catalog *Catalog driver Driver opts Options } // NewMailer returns a mailer that renders from cat and delivers through driver. func NewMailer(cat *Catalog, driver Driver, opts Options) Mailer { if cat == nil { cat = NewCatalog() } return &mailer{catalog: cat, driver: driver, opts: opts} } // Send renders msg.Template and delivers it. It performs no locale selection; // the caller must pass the full dotted name, including any -en suffix. func (m *mailer) Send(ctx context.Context, msg Message) error { if m == nil { return fmt.Errorf("postcard: mailer is nil") } if m.driver == nil { return fmt.Errorf("postcard: driver is nil") } if msg.Template == "" { return fmt.Errorf("postcard: template is empty") } if len(msg.To) == 0 && len(msg.Cc) == 0 && len(msg.Bcc) == 0 { return fmt.Errorf("postcard: no recipients") } rendered, err := m.catalog.render(msg, renderOptions{ css: m.opts.CSS, brandCSS: m.opts.BrandCSS, }) if err != nil { return err } rendered.From = m.opts.From _, smtp := m.driver.(*SMTPDriver) if err := validateRendered(rendered, smtp); err != nil { return err } if err := m.driver.Send(ctx, rendered); err != nil { return fmt.Errorf("postcard: %w", err) } return nil } // Activate publishes an app-scoped Mailer before plugin Boot. Catalog files // are validated later at each plugin's Boot transition. func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error { if app == nil { return fmt.Errorf("postcard: app is nil") } driver, opts, err := driverFromApp(app) if err != nil { return err } m := &mailer{ catalog: NewCatalog(), driver: driver, opts: opts, } if err := app.Publish[Mailer](m); err != nil { return fmt.Errorf("postcard: %w", err) } return nil } func driverFromApp(app *backpack.App) (Driver, Options, error) { opts := Options{} name := "memory" smtp := SMTPConfig{Port: 587, Timeout: 10 * time.Second} if app != nil && app.Config != nil { c := app.Config if v := strings.TrimSpace(c.String("mail.driver")); v != "" { name = strings.ToLower(v) } opts.From = strings.TrimSpace(c.String("mail.from")) opts.CSS = c.String("mail.css") opts.BrandCSS = c.String("mail.brandCss") smtp = smtpFromConfig(c) } switch name { case "memory": return NewMemoryDriver(), opts, nil case "log": return NewLogDriver(loggerFromApp(app)), opts, nil case "smtp": d, err := NewSMTPDriver(smtp) if err != nil { return nil, opts, err } return d, opts, nil default: return nil, opts, fmt.Errorf("postcard: unknown mail.driver %q (use memory, log, or smtp)", name) } } func smtpFromConfig(c *compass.Config) SMTPConfig { cfg := SMTPConfig{ Host: strings.TrimSpace(c.String("mail.smtp.host")), Port: c.Int("mail.smtp.port"), Username: c.String("mail.smtp.username"), Password: c.String("mail.smtp.password"), TLS: strings.TrimSpace(c.String("mail.smtp.tls")), Timeout: 10 * time.Second, } if raw := strings.TrimSpace(c.String("mail.smtp.timeout")); raw != "" { if d, err := time.ParseDuration(raw); err == nil { cfg.Timeout = d } else if n := c.Int("mail.smtp.timeout"); n > 0 { cfg.Timeout = time.Duration(n) * time.Second } } return cfg } func loggerFromApp(app *backpack.App) *slog.Logger { if app != nil { if log, ok := app.Lookup[*slog.Logger](); ok && log != nil { return log } } return slog.Default() } // BootPlugin registers a plugin's declared mail templates and layouts. // Missing files and unknown layout aliases fail with the dotted name. func BootPlugin[P interface{ ID() string }](app *backpack.App, p P) error { hm, ok := any(p).(pact.HasMailTemplates) if !ok { return nil } got, ok := app.Lookup[Mailer]() if !ok { return fmt.Errorf("postcard: mailer not published") } m, ok := got.(*mailer) if !ok || m == nil || m.catalog == nil { return fmt.Errorf("postcard: mailer not published") } return m.catalog.Register(p.ID(), hm.MailTemplatesFS(), hm.MailTemplates(), hm.MailLayouts()) }