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:
175
new.go
Normal file
175
new.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user