feat: implement go/smtp module — lifecycle, health, and email provider #1

Open
opened 2026-05-27 00:49:49 -06:00 by claude · 0 comments
Member

Overview

Implement the go/smtp module following the standard go-kit module pattern: a single *Client type returned by New() that satisfies launcher.Component, health.Checkable, and the module's own smtp.Sender interface.

SMTP is infrastructure — it must attach to the launcher lifecycle and report health. Inline implementation inside a consuming project (e.g. iron-dough-api) is explicitly out of scope.


Module structure

go/smtp/
  config.go      — smtp.Config (env struct tags)
  smtp.go        — New(), *Client type
  message.go     — Message, Attachment types
  template.go    — Template helper (html/template wrapper)
  noop.go        — no-op Sender when SMTP_HOST is empty

Config

smtp.Config owns its own struct with env tags. Consuming projects embed this type directly:

// smtp/config.go
type Config struct {
    Host     string `env:"SMTP_HOST"`
    Port     int    `env:"SMTP_PORT"     envDefault:"587"`
    User     string `env:"SMTP_USER"`
    Password string `env:"SMTP_PASSWORD"`
    From     string `env:"SMTP_FROM"`
}
// consuming project internal/config/config.go
type Config struct {
    SMTP smtp.Config
    // ...
}

Interfaces

// Sender is the interface consumers depend on — services only see this.
type Sender interface {
    Send(ctx context.Context, msg Message) error
}

*Client also implements launcher.Component (Start/Stop) and health.Checkable (Check). Callers depend only on the subset they need — the health registry only sees Checkable, services only see Sender.


Message and Attachment

type Message struct {
    To          []string
    CC          []string
    BCC         []string
    ReplyTo     string
    Subject     string
    Body        string      // pre-rendered: plain text or HTML string
    ContentType string      // "text/plain" | "text/html"
    Attachments []Attachment
}

type Attachment struct {
    Name        string
    ContentType string
    Data        io.Reader   // flexible: file, bytes.Reader, generated PDF, S3 stream
}

No-op mode

When Config.Host is empty, New() returns a no-op Sender that logs a warning and returns nil on every Send call. This allows the restaurant to run without configuring SMTP — email failure must never block a transaction.


Health check

  • Implements health.Checkable
  • Dials TCP to Host:Port (or sends SMTP NOOP) to verify reachability
  • Returns degraded (not down) — email is non-critical infrastructure
  • No-op client always returns healthy

Template helper

A thin Template wrapper around stdlib html/template for HTML email rendering. Rendering is intentionally separate from Send — the caller renders a template to a string and puts it in Message.Body. This keeps Send unit-testable without template involvement.

type Template struct{ t *template.Template }

func ParseFS(fsys fs.FS, patterns ...string) (*Template, error)
func (t *Template) Render(name string, data any) (string, error)

Wiring example (consuming project)

// internal/wire/launcher.go
client := smtp.New(cfg.SMTP)

launcher.Register(client)          // launcher.Component — Start/Stop lifecycle
health.Register("smtp", client)    // health.Checkable — degraded on unreachable
container.Provide(client)          // smtp.Sender — consumed by receipt/notification services

Acceptance criteria

  • smtp.Config struct with env tags; no config defined outside this module
  • New(cfg Config) *Client — returns no-op when cfg.Host is empty
  • *Client implements smtp.Sender, launcher.Component, health.Checkable
  • Message supports To, CC, BCC, ReplyTo, Subject, Body, ContentType, Attachments
  • Attachment.Data is io.Reader (not []byte)
  • Health check returns degraded (not down) when SMTP host is unreachable
  • Template wrapper over html/template with ParseFS + Render
  • No-op mode logs warning and returns nil on Send
  • Unit tests: Send with no-op client, health check degraded path, template rendering
## Overview Implement the `go/smtp` module following the standard go-kit module pattern: a single `*Client` type returned by `New()` that satisfies `launcher.Component`, `health.Checkable`, and the module's own `smtp.Sender` interface. SMTP is infrastructure — it must attach to the launcher lifecycle and report health. Inline implementation inside a consuming project (e.g. iron-dough-api) is explicitly out of scope. --- ## Module structure ``` go/smtp/ config.go — smtp.Config (env struct tags) smtp.go — New(), *Client type message.go — Message, Attachment types template.go — Template helper (html/template wrapper) noop.go — no-op Sender when SMTP_HOST is empty ``` --- ## Config `smtp.Config` owns its own struct with env tags. Consuming projects embed this type directly: ```go // smtp/config.go type Config struct { Host string `env:"SMTP_HOST"` Port int `env:"SMTP_PORT" envDefault:"587"` User string `env:"SMTP_USER"` Password string `env:"SMTP_PASSWORD"` From string `env:"SMTP_FROM"` } ``` ```go // consuming project internal/config/config.go type Config struct { SMTP smtp.Config // ... } ``` --- ## Interfaces ```go // Sender is the interface consumers depend on — services only see this. type Sender interface { Send(ctx context.Context, msg Message) error } ``` `*Client` also implements `launcher.Component` (Start/Stop) and `health.Checkable` (Check). Callers depend only on the subset they need — the health registry only sees `Checkable`, services only see `Sender`. --- ## Message and Attachment ```go type Message struct { To []string CC []string BCC []string ReplyTo string Subject string Body string // pre-rendered: plain text or HTML string ContentType string // "text/plain" | "text/html" Attachments []Attachment } type Attachment struct { Name string ContentType string Data io.Reader // flexible: file, bytes.Reader, generated PDF, S3 stream } ``` --- ## No-op mode When `Config.Host` is empty, `New()` returns a no-op `Sender` that logs a warning and returns `nil` on every `Send` call. This allows the restaurant to run without configuring SMTP — email failure must never block a transaction. --- ## Health check - Implements `health.Checkable` - Dials TCP to `Host:Port` (or sends SMTP NOOP) to verify reachability - Returns `degraded` (not `down`) — email is non-critical infrastructure - No-op client always returns `healthy` --- ## Template helper A thin `Template` wrapper around stdlib `html/template` for HTML email rendering. Rendering is intentionally **separate** from `Send` — the caller renders a template to a string and puts it in `Message.Body`. This keeps `Send` unit-testable without template involvement. ```go type Template struct{ t *template.Template } func ParseFS(fsys fs.FS, patterns ...string) (*Template, error) func (t *Template) Render(name string, data any) (string, error) ``` --- ## Wiring example (consuming project) ```go // internal/wire/launcher.go client := smtp.New(cfg.SMTP) launcher.Register(client) // launcher.Component — Start/Stop lifecycle health.Register("smtp", client) // health.Checkable — degraded on unreachable container.Provide(client) // smtp.Sender — consumed by receipt/notification services ``` --- ## Acceptance criteria - [ ] `smtp.Config` struct with env tags; no config defined outside this module - [ ] `New(cfg Config) *Client` — returns no-op when `cfg.Host` is empty - [ ] `*Client` implements `smtp.Sender`, `launcher.Component`, `health.Checkable` - [ ] `Message` supports `To`, `CC`, `BCC`, `ReplyTo`, `Subject`, `Body`, `ContentType`, `Attachments` - [ ] `Attachment.Data` is `io.Reader` (not `[]byte`) - [ ] Health check returns `degraded` (not `down`) when SMTP host is unreachable - [ ] `Template` wrapper over `html/template` with `ParseFS` + `Render` - [ ] No-op mode logs warning and returns `nil` on `Send` - [ ] Unit tests: `Send` with no-op client, health check degraded path, template rendering
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: go/smtp#1