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:
305
compliance_test.go
Normal file
305
compliance_test.go
Normal 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 }
|
||||
Reference in New Issue
Block a user