Files
smtp/new.go

176 lines
5.1 KiB
Go
Raw Normal View History

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
package smtp
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"mime/multipart"
"mime/quotedprintable"
"net"
netsmtp "net/smtp"
"net/textproto"
"strings"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/contracts/observability"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// Compile-time interface verification (I-8 / CT-5).
var _ Component = (*smtpClient)(nil)
var _ Component = (*noopClient)(nil)
var _ observability.Identifiable = (*smtpClient)(nil)
var _ observability.Identifiable = (*noopClient)(nil)
// New returns a Component backed by the given configuration.
// When cfg.Host is empty, New returns a no-op client that logs a warning
// and silently discards every message — SMTP absence must never block a transaction.
func New(logger logging.Logger, cfg Config) Component {
if cfg.Host == "" {
logger.Warn("smtp: host not configured, using no-op sender")
return &noopClient{logger: logger}
}
return &smtpClient{logger: logger, cfg: cfg}
}
// --- smtpClient ---
type smtpClient struct {
logger logging.Logger
cfg Config
}
func (c *smtpClient) OnInit() error { return nil }
func (c *smtpClient) OnStart() error { return nil }
func (c *smtpClient) OnStop() error { return nil }
func (c *smtpClient) Name() string { return "smtp" }
func (c *smtpClient) Priority() observability.Level { return observability.LevelDegraded }
func (c *smtpClient) HealthCheck(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port)
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
if err != nil {
return xerrors.New(xerrors.ErrUnavailable, "smtp: unreachable").WithError(err)
}
_ = conn.Close()
return nil
}
func (c *smtpClient) Send(_ context.Context, msg Message) error {
raw, err := buildRawMessage(c.cfg.From, msg)
if err != nil {
return xerrors.New(xerrors.ErrInternal, "smtp: build message").WithError(err)
}
all := make([]string, 0, len(msg.To)+len(msg.CC)+len(msg.BCC))
all = append(all, msg.To...)
all = append(all, msg.CC...)
all = append(all, msg.BCC...)
addr := fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port)
var auth netsmtp.Auth
if c.cfg.User != "" {
auth = netsmtp.PlainAuth("", c.cfg.User, c.cfg.Password, c.cfg.Host)
}
if err := netsmtp.SendMail(addr, auth, c.cfg.From, all, raw); err != nil {
return xerrors.New(xerrors.ErrInternal, "smtp: send").WithError(err)
}
c.logger.Info("smtp: sent", "subject", msg.Subject, "to", msg.To)
return nil
}
// buildRawMessage constructs a RFC 5322 MIME message ready for smtp.SendMail.
// BCC recipients are included in the SMTP envelope (via the caller) but are
// intentionally omitted from the message headers.
func buildRawMessage(from string, msg Message) ([]byte, error) {
var buf bytes.Buffer
ct := msg.ContentType
if ct == "" {
ct = "text/plain"
}
if len(msg.Attachments) == 0 {
writeHeader(&buf, "From", from)
writeHeader(&buf, "To", strings.Join(msg.To, ", "))
if len(msg.CC) > 0 {
writeHeader(&buf, "Cc", strings.Join(msg.CC, ", "))
}
if msg.ReplyTo != "" {
writeHeader(&buf, "Reply-To", msg.ReplyTo)
}
writeHeader(&buf, "Subject", msg.Subject)
writeHeader(&buf, "MIME-Version", "1.0")
writeHeader(&buf, "Content-Type", ct+"; charset=utf-8")
buf.WriteString("\r\n")
buf.WriteString(msg.Body)
return buf.Bytes(), nil
}
// multipart/mixed for messages with attachments.
// NewWriter is created first to obtain the boundary before writing headers.
mw := multipart.NewWriter(&buf)
writeHeader(&buf, "From", from)
writeHeader(&buf, "To", strings.Join(msg.To, ", "))
if len(msg.CC) > 0 {
writeHeader(&buf, "Cc", strings.Join(msg.CC, ", "))
}
if msg.ReplyTo != "" {
writeHeader(&buf, "Reply-To", msg.ReplyTo)
}
writeHeader(&buf, "Subject", msg.Subject)
writeHeader(&buf, "MIME-Version", "1.0")
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+mw.Boundary())
buf.WriteString("\r\n")
bodyHeader := textproto.MIMEHeader{}
bodyHeader.Set("Content-Type", ct+"; charset=utf-8")
bodyHeader.Set("Content-Transfer-Encoding", "quoted-printable")
bodyPart, err := mw.CreatePart(bodyHeader)
if err != nil {
return nil, err
}
qpw := quotedprintable.NewWriter(bodyPart)
if _, err := io.WriteString(qpw, msg.Body); err != nil {
return nil, err
}
if err := qpw.Close(); err != nil {
return nil, err
}
for _, att := range msg.Attachments {
attCT := att.ContentType
if attCT == "" {
attCT = "application/octet-stream"
}
attHeader := textproto.MIMEHeader{}
attHeader.Set("Content-Type", attCT)
attHeader.Set("Content-Transfer-Encoding", "base64")
attHeader.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Name))
attPart, err := mw.CreatePart(attHeader)
if err != nil {
return nil, err
}
enc := base64.NewEncoder(base64.StdEncoding, attPart)
if _, err := io.Copy(enc, att.Data); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
}
if err := mw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeHeader(buf *bytes.Buffer, key, value string) {
fmt.Fprintf(buf, "%s: %s\r\n", key, value)
}