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
This commit is contained in:
2026-05-29 16:05:03 +00:00
commit bfec1761d0
20 changed files with 1939 additions and 0 deletions

33
template.go Normal file
View File

@@ -0,0 +1,33 @@
package smtp
import (
"html/template"
"io/fs"
"strings"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// Template wraps stdlib html/template for HTML email rendering.
// Rendering is intentionally separate from [Sender.Send] — callers render
// a template to a string and assign it to [Message.Body].
type Template struct{ t *template.Template }
// ParseFS parses the named pattern files from fsys into a Template.
// Returns an error if any pattern matches no files or a file fails to parse.
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
t, err := template.ParseFS(fsys, patterns...)
if err != nil {
return nil, xerrors.New(xerrors.ErrInvalidInput, "smtp: parse template").WithError(err)
}
return &Template{t: t}, nil
}
// Render executes the named template with data and returns the rendered string.
func (t *Template) Render(name string, data any) (string, error) {
var buf strings.Builder
if err := t.t.ExecuteTemplate(&buf, name, data); err != nil {
return "", xerrors.New(xerrors.ErrInternal, "smtp: render template").WithError(err)
}
return buf.String(), nil
}