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

305
compliance_test.go Normal file
View File

@@ -0,0 +1,305 @@
package smtp
import (
"bytes"
"context"
"errors"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"code.nochebuena.dev/einherjar/contracts/lifecycle"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/contracts/observability"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// Compile-time interface checks (CT-5 / I-8).
var _ lifecycle.Component = (Component)(nil)
var _ observability.Checkable = (Component)(nil)
var _ Sender = (Component)(nil)
// --- CT-6: at most one exported TypeSpec per non-test, non-doc file ---
func TestAtMostOneExportedTypePerFile(t *testing.T) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
name := fi.Name()
return !strings.HasSuffix(name, "_test.go") && name != "doc.go"
}, 0)
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, pkg := range pkgs {
for path, file := range pkg.Files {
base := filepath.Base(path)
count := 0
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if ok && ts.Name.IsExported() {
count++
}
}
}
if count > 1 {
t.Errorf("%s: %d exported TypeSpecs (max 1)", base, count)
}
}
}
}
// --- Config defaults (S-4) ---
func TestDefaultConfig_OptionalFields(t *testing.T) {
cfg := DefaultConfig()
if cfg.Port == 0 {
t.Error("Port must have a default")
}
}
// --- New: noop when host empty ---
func TestNew_ReturnsNoopWhenHostEmpty(t *testing.T) {
c := New(newLogger(), Config{})
if c == nil {
t.Fatal("New returned nil")
}
// noop: Name and Priority are still defined
if c.Name() != "smtp" {
t.Errorf("Name() = %q, want smtp", c.Name())
}
}
func TestNew_ReturnsClientWhenHostSet(t *testing.T) {
c := New(newLogger(), Config{Host: "mail.example.com", Port: 587})
if c == nil {
t.Fatal("New returned nil")
}
if _, ok := c.(*smtpClient); !ok {
t.Errorf("expected *smtpClient, got %T", c)
}
}
// --- noopClient ---
func TestNoopClient_Send_ReturnsNil(t *testing.T) {
c := New(newLogger(), Config{})
err := c.Send(context.Background(), Message{Subject: "test", To: []string{"a@b.com"}})
if err != nil {
t.Errorf("noop Send must return nil, got %v", err)
}
}
func TestNoopClient_HealthCheck_ReturnsNil(t *testing.T) {
c := New(newLogger(), Config{})
if err := c.HealthCheck(context.Background()); err != nil {
t.Errorf("noop HealthCheck must return nil, got %v", err)
}
}
func TestNoopClient_Priority_IsDegraded(t *testing.T) {
c := New(newLogger(), Config{})
if c.Priority() != observability.LevelDegraded {
t.Error("Priority() must be LevelDegraded")
}
}
func TestNoopClient_Lifecycle_ReturnsNil(t *testing.T) {
c := New(newLogger(), Config{})
if err := c.OnInit(); err != nil {
t.Errorf("OnInit: %v", err)
}
if err := c.OnStart(); err != nil {
t.Errorf("OnStart: %v", err)
}
if err := c.OnStop(); err != nil {
t.Errorf("OnStop: %v", err)
}
}
// --- smtpClient metadata ---
func TestSmtpClient_Priority_IsDegraded(t *testing.T) {
c := New(newLogger(), Config{Host: "mail.example.com", Port: 587})
if c.Priority() != observability.LevelDegraded {
t.Error("Priority() must be LevelDegraded")
}
}
func TestSmtpClient_Name(t *testing.T) {
c := New(newLogger(), Config{Host: "mail.example.com", Port: 587})
if c.Name() != "smtp" {
t.Errorf("Name() = %q, want smtp", c.Name())
}
}
// --- buildRawMessage ---
func TestBuildRawMessage_NoAttachments_PlainText(t *testing.T) {
msg := Message{
To: []string{"bob@example.com"},
Subject: "Hello",
Body: "plain body",
ContentType: "text/plain",
}
raw, err := buildRawMessage("alice@example.com", msg)
if err != nil {
t.Fatalf("buildRawMessage: %v", err)
}
s := string(raw)
for _, want := range []string{"From: alice@example.com", "To: bob@example.com", "Subject: Hello", "plain body"} {
if !strings.Contains(s, want) {
t.Errorf("raw message missing %q", want)
}
}
}
func TestBuildRawMessage_NoAttachments_HTML(t *testing.T) {
msg := Message{
To: []string{"bob@example.com"},
Subject: "Receipt",
Body: "<b>Hello</b>",
ContentType: "text/html",
}
raw, err := buildRawMessage("alice@example.com", msg)
if err != nil {
t.Fatalf("buildRawMessage: %v", err)
}
if !strings.Contains(string(raw), "text/html") {
t.Error("raw message missing text/html content type")
}
}
func TestBuildRawMessage_WithCC_ReplyTo(t *testing.T) {
msg := Message{
To: []string{"bob@example.com"},
CC: []string{"carol@example.com"},
ReplyTo: "noreply@example.com",
Subject: "FYI",
Body: "body",
}
raw, err := buildRawMessage("alice@example.com", msg)
if err != nil {
t.Fatalf("buildRawMessage: %v", err)
}
s := string(raw)
if !strings.Contains(s, "Cc: carol@example.com") {
t.Error("missing Cc header")
}
if !strings.Contains(s, "Reply-To: noreply@example.com") {
t.Error("missing Reply-To header")
}
}
func TestBuildRawMessage_WithAttachment(t *testing.T) {
msg := Message{
To: []string{"bob@example.com"},
Subject: "Report",
Body: "<p>See attached</p>",
ContentType: "text/html",
Attachments: []Attachment{
{
Name: "report.pdf",
ContentType: "application/pdf",
Data: bytes.NewReader([]byte("PDF content")),
},
},
}
raw, err := buildRawMessage("alice@example.com", msg)
if err != nil {
t.Fatalf("buildRawMessage: %v", err)
}
s := string(raw)
if !strings.Contains(s, "multipart/mixed") {
t.Error("missing multipart/mixed content type")
}
if !strings.Contains(s, "report.pdf") {
t.Error("missing attachment filename")
}
if !strings.Contains(s, "application/pdf") {
t.Error("missing attachment content type")
}
}
func TestBuildRawMessage_BCC_NotInHeaders(t *testing.T) {
msg := Message{
To: []string{"bob@example.com"},
BCC: []string{"hidden@example.com"},
Subject: "Secret",
Body: "body",
}
raw, err := buildRawMessage("alice@example.com", msg)
if err != nil {
t.Fatalf("buildRawMessage: %v", err)
}
if strings.Contains(string(raw), "hidden@example.com") {
t.Error("BCC recipient must not appear in message headers")
}
}
// --- Template ---
func TestTemplate_Render(t *testing.T) {
fsys := fstest.MapFS{
"email.html": &fstest.MapFile{
Data: []byte(`Hello, {{.Name}}!`),
},
}
tmpl, err := ParseFS(fsys, "email.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
result, err := tmpl.Render("email.html", map[string]string{"Name": "Alice"})
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(result, "Alice") {
t.Errorf("want Alice in rendered output, got %q", result)
}
}
func TestTemplate_Render_UnknownTemplate(t *testing.T) {
fsys := fstest.MapFS{
"email.html": &fstest.MapFile{Data: []byte(`Hello!`)},
}
tmpl, _ := ParseFS(fsys, "email.html")
_, err := tmpl.Render("does-not-exist.html", nil)
if err == nil {
t.Fatal("expected error for unknown template name")
}
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Errorf("expected *xerrors.Err, got %T", err)
}
}
func TestTemplate_ParseFS_Error(t *testing.T) {
fsys := fstest.MapFS{}
_, err := ParseFS(fsys, "nonexistent.html")
if err == nil {
t.Fatal("expected error for missing template file")
}
}
// --- helpers ---
type stubLogger struct{ warned bool }
func newLogger() *stubLogger { return &stubLogger{} }
func (s *stubLogger) Debug(msg string, args ...any) {}
func (s *stubLogger) Info(msg string, args ...any) {}
func (s *stubLogger) Warn(msg string, args ...any) { s.warned = true }
func (s *stubLogger) Error(msg string, err error, args ...any) {}
func (s *stubLogger) With(args ...any) logging.Logger { return s }
func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s }