Files
smtp/README.md
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

3.5 KiB

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.