smtp

package
v0.0.1-alpha.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package smtp provides a minimal SMTP server for capturing outbound emails in local development, and a Mailer interface for sending emails that works with both the built-in capture server and an external SMTP relay.

It also provides an SMSSender interface for capturing outbound SMS messages into the same MailStore so they appear in the inbox alongside emails.

Architecture:

SNS handler (email/email-json subscriber)
    └─ Mailer.Send(...)
           │
    ┌──────┴──────┐
    │             │
  NetMailer    NetMailer
  → localhost   → external relay
  :1025          (host:port + auth)
    │
    ▼
  Server (RFC 5321 TCP listener)
    └─ MailStore (ring buffer, queryable via HTTP)

SNS handler (sms subscriber) / Cognito (SMS codes)
    └─ SMSSender.Send(...)
           │
    MockSMSSender → MailStore (ring buffer, queryable via HTTP)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildMessage

func BuildMessage(from string, to []string, subject, body, html string, extraHeaders map[string]string) []byte

BuildMessage constructs a minimal RFC 2822 message. When html is non-empty a multipart/alternative message is produced; otherwise plain text only. extraHeaders is an optional map of additional header fields to inject before the body (e.g. X-Overcast-Group-Id for SNS fan-out threading). Pass nil to omit extra headers.

Types

type CapturedMessage

type CapturedMessage struct {
	// ID is a unique identifier assigned at capture time.
	ID string `json:"id"`

	// Kind is the transport type: "email" or "sms".
	Kind MessageKind `json:"kind"`

	// Source is the name of the service that sent the message
	// (e.g. "sns", "ses", "cognito"). Empty when unknown.
	Source string `json:"source,omitempty"`

	// From is the envelope sender address (MAIL FROM for email, or the
	// originator phone number / sender ID for SMS).
	From string `json:"from"`

	// To is the list of recipient addresses (email) or phone numbers (SMS).
	To []string `json:"to"`

	// Subject is the value of the Subject header. Always empty for SMS.
	Subject string `json:"subject,omitempty"`

	// TextBody is the plain-text body.
	TextBody string `json:"textBody"`

	// HTMLBody is the HTML body (text/html part), if present. Always empty for SMS.
	HTMLBody string `json:"htmlBody,omitempty"`

	// ReceivedAt is the UTC timestamp when the message was captured.
	ReceivedAt time.Time `json:"receivedAt"`

	// Raw is the complete RFC 5321 DATA payload, verbatim. Empty for SMS.
	Raw string `json:"raw,omitempty"`

	// GroupID ties all deliveries for a single SNS Publish call together so
	// the inbox UI can show them as a thread. It is the SNS MessageId when
	// set by the SNS fan-out; empty for standalone messages (SES, Cognito).
	GroupID string `json:"groupId,omitempty"`

	// GroupTopic is the short topic name associated with GroupID, shown as
	// the thread title in the inbox list.
	GroupTopic string `json:"groupTopic,omitempty"`
}

CapturedMessage holds a single message captured by the mock SMTP server or mock SMS sender. The Kind field distinguishes email from SMS.

func NewPushMessage

func NewPushMessage(source, endpoint, body, groupID, groupTopic string) *CapturedMessage

NewPushMessage builds a CapturedMessage for an SNS application (mobile push) subscription delivery. endpoint is the device ARN; body is the notification payload. groupID and groupTopic link this delivery to an SNS fan-out batch.

func NewSMSMessage

func NewSMSMessage(source, sender, to, body, groupID, groupTopic string) *CapturedMessage

NewSMSMessage builds a CapturedMessage for an outbound SMS. source is the service name (e.g. "sns", "cognito") and sender is the originator ID or phone number ("" is acceptable when unavailable). groupID and groupTopic link this message to an SNS fan-out batch; pass "" for standalone messages.

func NewWebhookMessage

func NewWebhookMessage(source, endpoint, body, groupID, groupTopic string) *CapturedMessage

NewWebhookMessage builds a CapturedMessage for an SNS http/https subscription delivery. endpoint is the destination URL; body is the JSON notification payload. groupID and groupTopic link this delivery to an SNS fan-out batch.

type Config

type Config struct {
	// Host is the SMTP server hostname or IP address.
	Host string

	// Port is the SMTP server port (e.g. 25, 465, 587, 1025).
	Port int

	// Username and Password are used for SMTP AUTH PLAIN. Leave empty to skip AUTH.
	Username string
	Password string

	// TLS controls whether to use implicit TLS (port 465 convention).
	// For STARTTLS on submission ports (587), set TLS=false — the client
	// upgrades automatically when the server advertises STARTTLS.
	TLS bool
}

Config holds the parameters needed to connect to an SMTP server.

func (Config) Addr

func (c Config) Addr() string

Addr returns "host:port".

type LazyMailer

type LazyMailer struct {
	// contains filtered or unexported fields
}

LazyMailer is a Mailer that blocks until an underlying Mailer becomes available. This lets startup continue while the mock SMTP server binds in the background — the first actual email Send will block briefly until the server is ready.

func NewLazyMailer

func NewLazyMailer() *LazyMailer

NewLazyMailer returns a LazyMailer. Call SetReady once the real Mailer is available.

func (*LazyMailer) Send

func (l *LazyMailer) Send(ctx context.Context, from string, to []string, subject, body, html string) error

Send implements Mailer.

func (*LazyMailer) SendRaw

func (l *LazyMailer) SendRaw(ctx context.Context, from string, to []string, msg []byte) error

SendRaw implements Mailer.

func (*LazyMailer) SetReady

func (l *LazyMailer) SetReady(m Mailer)

SetReady publishes the real Mailer. Must be called exactly once.

type MailStore

type MailStore struct {
	// contains filtered or unexported fields
}

MailStore is a thread-safe, capped ring buffer of captured SMTP messages. When the store is at capacity, the oldest message is evicted to make room.

func NewMailStore

func NewMailStore(maxMessages int) *MailStore

NewMailStore returns a MailStore that keeps at most maxMessages messages. If maxMessages is ≤ 0, defaultMaxMessages is used.

func (*MailStore) Add

func (s *MailStore) Add(m *CapturedMessage)

Add saves a new message. If the store is full the oldest message is dropped.

func (*MailStore) Clear

func (s *MailStore) Clear()

Clear removes all messages.

func (*MailStore) Delete

func (s *MailStore) Delete(id string) bool

Delete removes the message with the given ID. Returns true if a message was removed.

func (*MailStore) Get

func (s *MailStore) Get(id string) *CapturedMessage

Get returns the message with the given ID, or nil if not found.

func (*MailStore) Len

func (s *MailStore) Len() int

Len returns the current number of stored messages.

func (*MailStore) List

func (s *MailStore) List() []*CapturedMessage

List returns all messages in reverse-chronological order (newest first).

type Mailer

type Mailer interface {
	// Send delivers an email. from is the envelope sender; to is the list of
	// recipient addresses; subject and body are the message content;
	// html is an optional HTML alternative (empty string = plain text only).
	Send(ctx context.Context, from string, to []string, subject, body, html string) error

	// SendRaw delivers an already-assembled RFC 2822 MIME message. The msg
	// bytes are passed verbatim to the SMTP DATA command. Use this for
	// SES SendRawEmail where the caller supplies the full message.
	SendRaw(ctx context.Context, from string, to []string, msg []byte) error
}

Mailer is the interface used by SNS, SES, and Cognito handlers to send email notifications. Both the built-in mock server and external SMTP relays are accessed through this interface — callers never need to know which backend is in use.

Every method accepts a context. When the context is cancelled (e.g. the HTTP client disconnects), implementations must return promptly rather than leaking a goroutine and an ephemeral port.

type MessageKind

type MessageKind string

MessageKind distinguishes the transport type of a captured message.

const (
	// KindEmail is an SMTP-delivered email message.
	KindEmail MessageKind = "email"
	// KindSMS is an SMS message captured by the mock sender.
	KindSMS MessageKind = "sms"
	// KindWebhook is an SNS http/https subscription delivery captured in the inbox.
	KindWebhook MessageKind = "webhook"
	// KindPush is an SNS application (mobile push) delivery captured in the inbox.
	KindPush MessageKind = "push"
)

type MockOutboundCapture

type MockOutboundCapture struct {
	OnMessage func(*CapturedMessage) // optional callback invoked after each capture
	// contains filtered or unexported fields
}

MockOutboundCapture stores webhook and push deliveries in a MailStore.

func NewMockOutboundCapture

func NewMockOutboundCapture(store *MailStore, onMessage func(*CapturedMessage)) *MockOutboundCapture

NewMockOutboundCapture returns an OutboundCapture that stores deliveries into store.

func (*MockOutboundCapture) CapturePush

func (c *MockOutboundCapture) CapturePush(source, endpoint, body, groupID, groupTopic string) error

CapturePush implements OutboundCapture.

func (*MockOutboundCapture) CaptureWebhook

func (c *MockOutboundCapture) CaptureWebhook(source, endpoint, body, groupID, groupTopic string) error

CaptureWebhook implements OutboundCapture.

type MockSMSSender

type MockSMSSender struct {
	OnMessage func(*CapturedMessage) // optional callback invoked after each capture
	// contains filtered or unexported fields
}

MockSMSSender captures outbound SMS messages directly into a MailStore. It is used when no real SMS gateway is configured, which is the default.

func NewMockSMSSender

func NewMockSMSSender(store *MailStore) *MockSMSSender

NewMockSMSSender returns an SMSSender that captures messages into store.

func (*MockSMSSender) SendSMS

func (s *MockSMSSender) SendSMS(source, sender, to, body, groupID, groupTopic string) error

SendSMS implements SMSSender by storing the message in the capture store.

type NetMailer

type NetMailer struct {
	// contains filtered or unexported fields
}

NetMailer sends mail via a standard SMTP server. Unlike Go's smtp.SendMail (which uses net.Dial with no timeout), NetMailer uses DialTimeout so a stalled or unreachable server does not leak goroutines and ephemeral ports.

func NewMailer

func NewMailer(cfg Config) *NetMailer

NewMailer returns a NetMailer configured to send through cfg.

func (*NetMailer) Send

func (m *NetMailer) Send(ctx context.Context, from string, to []string, subject, body, html string) error

Send implements Mailer.

func (*NetMailer) SendRaw

func (m *NetMailer) SendRaw(ctx context.Context, from string, to []string, msg []byte) error

SendRaw implements Mailer.

type OutboundCapture

type OutboundCapture interface {
	// CaptureWebhook records an SNS http/https subscription delivery.
	// endpoint is the destination URL; body is the full JSON notification payload.
	// groupID and groupTopic link this delivery to an SNS fan-out batch.
	CaptureWebhook(source, endpoint, body, groupID, groupTopic string) error

	// CapturePush records an SNS application (mobile push) subscription delivery.
	// endpoint is the device ARN; body is the notification payload JSON.
	// groupID and groupTopic link this delivery to an SNS fan-out batch.
	CapturePush(source, endpoint, body, groupID, groupTopic string) error
}

OutboundCapture is a shared capture handle used by SNS to record webhook and mobile-push deliveries in the inbox. A single implementation (MockOutboundCapture) writes directly into a MailStore; future implementations could forward to real endpoints.

type SMSSender

type SMSSender interface {
	// SendSMS delivers an SMS message. source identifies the service name
	// (e.g. "sns", "cognito") for display; sender is the originator phone
	// number or sender ID (may be empty); to is the destination phone number;
	// body is the message text. groupID and groupTopic link this message to
	// an SNS fan-out batch; pass "" for standalone messages.
	SendSMS(source, sender, to, body, groupID, groupTopic string) error
}

SMSSender is the interface used by SNS and Cognito handlers to send SMS notifications in local development. The only implementation is MockSMSSender, which captures messages into a MailStore so they are visible in the inbox UI.

type Server

type Server struct {

	// OnMessage is an optional callback invoked after every successfully
	// captured message has been stored. It runs in the connection goroutine
	// and must not block. Set before calling Serve.
	OnMessage func(*CapturedMessage)
	// contains filtered or unexported fields
}

Server is a minimal RFC 5321 SMTP server that captures all inbound messages into a MailStore. It implements just enough of the SMTP protocol to accept messages from standard smtp clients (including Go's net/smtp package): EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT.

It does not perform any authentication or TLS. It is intended for local development use only — never expose on a public network.

Usage:

srv := smtp.NewServer("127.0.0.1:1025", store)
addr, err := srv.Listen()   // bind the TCP socket (synchronous)
go srv.Serve(ctx)            // start accepting (runs until ctx is done)

func NewServer

func NewServer(addr string, store *MailStore) *Server

NewServer creates a Server that will bind to addr (e.g. "127.0.0.1:1025") and store captured messages in store. Use ":0" for a random OS-assigned port.

func (*Server) Close

func (s *Server) Close() error

Close closes the listener, causing Serve to stop accepting new connections. Any in-flight connections are allowed to finish.

func (*Server) Listen

func (s *Server) Listen() (string, error)

Listen binds the TCP socket and returns the actual address (useful when the configured port is 0). It must be called before Serve.

func (*Server) Serve

func (s *Server) Serve(ctx context.Context)

Serve begins accepting connections. It blocks until ctx is done and all in-flight connections are closed. Listen must be called first.

func (*Server) Start

func (s *Server) Start(ctx context.Context) error

Start is a convenience method that calls Listen then Serve. It is provided for use in tests where the caller does not need the bound address.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL