Rene Nochebuena bfec1761d0 feat(smtp): initial implementation — SMTP client with no-op fallback and template rendering (v1.0.0)
Introduces code.nochebuena.dev/einherjar/smtp — the email delivery starter for the
Einherjar framework. New module with no micro-lib counterpart. Pure stdlib — no
external dependencies beyond contracts and core.

Interfaces (CT-6: one TypeSpec per file):
- Sender — Send(ctx context.Context, msg Message) error
- Component — lifecycle.Component + observability.Checkable + Sender
- Message — To, CC, BCC, ReplyTo, Subject, Body, ContentType, Attachments
- Attachment — Name, ContentType string; Data io.Reader (consumed once on Send)
- Template — wraps html/template for email rendering

Implementation:
- New(logger, cfg) Component — returns noopClient when cfg.Host is empty;
  SMTP absence must never block a transaction (e.g. account creation)
- noopClient: all lifecycle and Send methods return nil; Send logs Warn;
  Priority LevelDegraded; Name "smtp"
- smtpClient: OnInit/OnStart/OnStop are no-ops (stateless stdlib client);
  HealthCheck: TCP dial to Host:Port; Priority LevelDegraded
- Send: buildRawMessage → netsmtp.SendMail; BCC passed in SMTP envelope
  (RCPT TO) but never written to message headers (RFC 5321/5322 compliance)
- buildRawMessage: plain message without attachments; multipart/mixed with
  quoted-printable body + base64 attachments when Attachments non-empty;
  multipart.NewWriter created first to obtain boundary before headers are written
- ParseFS(fsys, patterns...) (*Template, error) — wraps html/template.ParseFS
- Template.Render(name, data) (string, error) — executes named template
- net/smtp aliased as netsmtp to avoid package-name collision in package smtp

Config (EINHERJAR_SMTP_* env vars):
  Host, Port(587), User, Password, From

- Component interface embeds observability.Identifiable; identifiable.go implements
  ModulePath and ModuleVersion on both smtpClient and noopClient via
  runtime/debug.ReadBuildInfo() — prints in launcher banner
2026-05-29 16:05:03 +00:00

einherjar/smtp

version license go health

A raven sent from Valhalla reaches its destination. The sender does not wait at the window.

code.nochebuena.dev/einherjar/smtp is the email sender component of the Einherjar framework. It is built entirely on the Go standard library (net/smtp, mime/multipart, html/template) with no external dependencies. When EINHERJAR_SMTP_HOST is empty, New returns a silent no-op — email absence never blocks a transaction, user registration, or order placement. Health priority is LevelDegraded.


Usage

Setup

import "code.nochebuena.dev/einherjar/smtp"

mailer := smtp.New(logger, smtp.DefaultConfig())
launcher.Append(mailer)   // OnInit verifies credentials; OnStop is a no-op
health.Register(mailer)   // dial check; LevelDegraded

When cfg.Host is empty, New returns a no-op that logs every Send call at debug level and always returns nil. No code changes are needed between environments.

Sending a plain-text email

err := mailer.Send(ctx, smtp.Message{
    To:      []string{"user@example.com"},
    Subject: "Welcome to the service",
    Body:    "Your account is ready.",
})

Sending HTML with attachments

err := mailer.Send(ctx, smtp.Message{
    To:          []string{"user@example.com"},
    CC:          []string{"support@example.com"},
    BCC:         []string{"audit@example.com"},   // envelope only — never in headers
    Subject:     "Your invoice",
    Body:        renderedHTML,
    ContentType: "text/html",
    Attachments: []smtp.Attachment{
        {
            Name:        "invoice.pdf",
            ContentType: "application/pdf",
            Data:        pdfReader,   // consumed once; do not reuse
        },
    },
})

Template rendering

Template rendering is intentionally separate from Send. Render to a string first, then assign to Message.Body. This keeps the transport and rendering concerns independent.

tmpl, err := smtp.ParseFS(os.DirFS("templates"), "*.html")
if err != nil {
    return err
}

body, err := tmpl.Render("welcome.html", map[string]any{
    "Name": user.Name,
    "URL":  activationURL,
})
if err != nil {
    return err
}

err = mailer.Send(ctx, smtp.Message{
    To:          []string{user.Email},
    Subject:     "Activate your account",
    Body:        body,
    ContentType: "text/html",
})

Environment variables

Variable Required Default Description
EINHERJAR_SMTP_HOST No "" SMTP host. Empty → no-op sender
EINHERJAR_SMTP_PORT No 587 SMTP port
EINHERJAR_SMTP_USER No "" Auth username
EINHERJAR_SMTP_PASSWORD No "" Auth password
EINHERJAR_SMTP_FROM No "" Default From address

Dependency graph

contracts  (zero dependencies)
    ↑
  core
    ↑
smtp  (contracts, core, stdlib only)
    ↑
  your app

No external dependencies beyond the Go standard library.


Verification

cd smtp/
go build ./...
go vet ./...
go test ./...
gofmt -l .

The message matters. The raven is only the path. Make the path reliable. Do not let it stop the battle.

Description
SMTP email sender with attachments, BCC, template rendering, and silent no-op fallback
Readme 59 KiB
2026-05-29 10:05:22 -06:00
Languages
Go 100%