sender

package
v0.0.0-...-6651dc8 Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: MPL-2.0 Imports: 33 Imported by: 0

README

internal/sender

claim → 렌더 → SMTP 루프. 설계 근거는 architecture.md §4.1–4.2, §8, §9.2, §10, §16, ADR-0002 · ADR-0003 · ADR-0011.

루프 (§8.1)

Run
 ├─ heartbeatLoop      10s: Workers.Heartbeat + ListActive(now-30s) → 활성 레플리카 수
 └─ loop
     ├─ 3s: Provider.ActiveTenants + 언헬시 transport 프로브
     └─ pass()  테넌트 라운드로빈(커서 이동) × 레인(transactional → bulk → probe)
         claimInto: limit = min(레인 여유, 테넌트 여유, ClaimBatch)
           ├─ runningCampaigns(3s 캐시) → ClaimRequest.CampaignIDs
           ├─ 용량을 **미리 예약**하고 claim, 못 채운 만큼 즉시 반납
           └─ jobs 채널 → 레인 워커 풀
               process(d) → DeliveryResult → tenant batcher (100건 또는 1초)

Claim 전에 용량을 예약하기 때문에 두 번의 pass가 풀을 초과 구독할 수 없습니다. 종료 시에는 claim을 멈추고 레인 채널을 닫아 진행 중인 메일만 마무리한 뒤 배처를 flush 합니다.

process(d)

  1. 테넌트 설정(짧은 TTL 캐시, store.LoadTenantSettings — 행이 없으면 기본값으로 만들고 읽습니다) → 캠페인 / MessageVersion(불변, 영구 캐시) / Sender / Transport / SendingDomain
  2. suppression (SuppressionEnabled && lane ≠ probe) → suppressed. lane=probe는 수신거부·클릭 재작성·오픈 픽셀에서도 전부 빠집니다(§11.2, ADR-0012)
  3. transport를 쓸 수 있는지 판정(아래 "transport 상태") — 못 쓰면 delivery는 queued 유지(시도 미소모)
  4. 수신거부 목적지: 수신자 변수(Delivery.UnsubscribeURL → Vars["unsubscribe_url"]) > 테넌트 Liquid 템플릿 > Hooks.UnsubscribeURL
  5. 모드별 링크 결정(아래 표) → {{ unsubscribe_url }} 바인딩에 렌더 전에 주입
  6. PrepareChain(version, recipient.locale, campaign.default_locale) → Render
  7. 클릭 재작성(링크마다 토큰) → 오픈 픽셀 삽입 (§9.2)
  8. Hooks.BeforeSend — ErrSkip → suppressed, 그 밖의 에러 → transient(재시도 소모)
  9. MIME 조립 + 헤더 인젝션 검사 + 선택적 DKIM 서명. 커스텀 헤더는 Delivery.Headers(POST /messages) + Vars["probe_token"](→ X-Sendplane-Probe) + BeforeSend가 더한 것이고, 셋 다 같은 화이트리스트(ValidateCustomHeader)를 통과해야 합니다
  10. 레이트리밋 대기(transport 버킷, (transport, 수신 도메인) 버킷)
  11. 풀 커넥션으로 전송 → Classify → 250이면 즉시 MarkSent, 결과는 배치 Complete
수신거부 모드 (§9.2)
mode 본문 {{ unsubscribe_url }} List-Unsubscribe List-Unsubscribe-Post
sendplane (도메인·키 있음) https://{tracking_domain}/t/u/{token} 같은 URL List-Unsubscribe=One-Click
sendplane (도메인 또는 키 없음) 호스트 목적지 그대로 같은 URL 없음
sendplane (목적지 없음) 없음 없음 없음
host 호스트 목적지 같은 URL TenantSettings.UnsubscribeOneClick 이고 목적지가 https일 때만
none 없음 없음 없음

파일

파일 내용
sender.go Config(+기본값), 레인 풀, 라운드로빈 pass, 하트비트, 프로브
process.go 수신자 1명 파이프라인, 수신거부 해석, 결과 조립
message.go MIME(go-mail), 헤더 화이트리스트/인젝션 검사, DKIM(go-msgauth)
classify.go SMTP 코드·enhanced·본문 → store.ErrorClass 규칙 테이블
policy.go 재시도/백오프/지터, IncrementAttempt, auth → queued
ratelimit.go 토큰 버킷 + 워커 수 분할 + AIMD
pool.go transport별 커넥션 풀
results.go Complete 배치 커밋 + delivery.sent/delivery.failed outbox (§12)
health.go transport 서킷(cooldown/unhealthy) + StatusUntil 영속화/조정
cache.go 테넌트별 TTL 캐시, 복호화된 비밀 캐시
metrics.go 메트릭 이름 상수 (싱크는 host.Metrics)

delivery 이벤트 (§12)

배치 Complete 가 커밋된 뒤에 같은 스토어의 outbox에 씁니다 — 캠페인/바운스 이벤트와 같은 outbox 패턴이라 저장소를 가로지르는 트랜잭션이 없습니다. sent → delivery.sent, failed(종단) → delivery.failed. 그 외 상태는 이벤트가 없습니다: deferred 는 종단이 아니고, suppressed/bounced 는 그 판정을 내리는 쪽의 몫입니다.

테넌트가 구독한 것만 씁니다(TenantSettings.EventTypes, 비어 있으면 기본 집합). delivery.sent 는 기본 집합에서 빠져 있습니다 — 계약이 delivery 하나당 행 하나라서 100만 수신자 캠페인이면 행도 POST도 100만 건입니다. enqueue가 실패해도 delivery 결과는 이미 커밋돼 있으므로 로그만 남기고 넘어갑니다. 되돌리면 결과를 다시 쓰게 됩니다.

에러 분류 테이블 (§4.2)

위에서부터 첫 매치가 이깁니다. 한 규칙 안에서 코드/enhanced/본문 조건은 AND, 각 조건의 항목들은 OR.

규칙 조건 class
auth.code 530, 534, 535, 538 auth
auth.enhanced 5.7.8 / 5.7.9 / 4.7.8 auth
ratelimit.421 421 rate_limited
ratelimit.4xx.text 450·451·452 + rate|too many|too quickly|throttl|slow down|deferred due to rate_limited
ratelimit.enhanced 4.7.0 / 4.7.28 / 4.2.1 rate_limited
policy.enhanced 5.7.* policy
policy.text 5xx + spam|blocked|blacklist|blocklist|reputation|policy|abuse policy
permanent.5xx 5xx permanent
transient.4xx 4xx transient
auth.tls 인증서 검증 실패, TLS 레코드 오류 auth
transient.timeout / transient.connection 타임아웃, EOF, *net.OpError transient
transient.unknown 그 외 transient

auth 가 policy 보다 위인 것이 중요합니다 — 535는 enhanced가 5.7.8이라 순서를 바꾸면 policy로 잘못 분류됩니다.

ratelimit.4xx.text 에는 일부러 "try again later" 를 넣지 않습니다 — Postfix/Exim 이 평범한 일시 실패에도 쓰는 상투 문구라(internal/chaossmtp 의 tempfail 응답이 그 예: 451 4.3.0 Temporary local problem, try again later) 너무 넓게 잡혀 진짜 일시 실패까지 rate_limited 로 오분류했습니다. 이 문구를 쓰는 진짜 레이트리밋 응답은 421(ratelimit.421) 이거나 enhanced code 4.7.0/4.7.28/4.2.1(ratelimit.enhanced) 을 동반하므로 다른 규칙이 잡습니다.

문서는 이 표를 YAML로 두자고 했지만 Go 리터럴로 두었습니다: 어차피 컴파일되어야 하고, 규칙과 테스트가 같은 디렉터리에서 함께 움직이는 편이 낫습니다. 테넌트별 오버라이드가 필요해지면 같은 행 구조를 YAML에서 읽어 넣으면 됩니다.

정책 (§4.1, ADR-0003)

  • transient / rate_limited 만 시도를 소모. 백오프는 테넌트 RetryPolicy + 지터 ±20%, 마지막 항목 재사용.
  • rate_limited 는 RateLimitCap(기본 5분)으로 상한 — 실제 간격 조절은 레이트리미터가 합니다.
  • permanent / policy → failed, 시도 미소모.
  • auth → delivery는 queued 유지, 시도 미소모, AuthRetryAfter(기본 1분) 뒤 재적격 + transport unhealthy 마킹.
  • 렌더 실패·헤더 인젝션·MIME 실패 → failed(permanent). 같은 입력이면 재시도해도 같은 결과입니다.
  • 스토어 읽기 실패 등 sender 자신의 문제는 queued + 30초, 시도 미소모.

transport 상태 (§8.3)

replica의 로컬 서킷 하나만으로는 부족합니다. 실패를 본 적 없는 replica는 클러스터가 이미 고장으로 아는 transport로 메일을 계속 보내고, unhealthy 를 쓴 replica가 그대로 죽으면 아무도 그 상태를 풀어 주지 않습니다. 그래서 Transport.StatusUntil 이 심판을 봅니다:

  • cooldown/unhealthy 를 쓸 때마다 StatusUntil = now + TransportProbeInterval 을 같이 씁니다. healthy 는 zero.
  • 저장된 상태가 unhealthy 면 로컬 서킷과 무관하게 건너뜁니다.
  • 단, StatusUntil 이 이미 지났으면 로컬 상태와 무관하게 다시 시도합니다 — 다음 delivery가 곧 프로브입니다.

rate_limited 는 한 번의 응답으로 cooldown에 들어가지 않습니다. healthy → cooldown 전환에는 최근 rateLimitWindow(기본 1분) 안에 rateLimitThreshold(기본 3)개 이상의 rate_limited 인스턴트가 쌓여야 합니다(transportHealth.rateLimits, 매 fail/success 호출마다 창을 가지치기합니다). 반대로 cooldown → healthy 복귀는 창이 완전히 빌 때만, 즉 마지막 rate_limited 이후 rateLimitWindow 가 통째로 지나야 일어납니다 — 그다음 바로 온 성공 한 건으로는 돌아오지 않습니다. 수 퍼센트의 응답이 산발적으로 deferred되는 정상 트래픽에서 cooldown/healthy를 매 전송마다 오가며 DB에 쓰고 outbox 이벤트를 내는 플래핑을 막기 위한 장치입니다. 이 두 값은 Sender.Config 가 아니라 internal/sender/health.go 의 패키지 상수 (defaultRateLimitThreshold, defaultRateLimitWindow)입니다 — 인증/TLS/connect 실패에 쓰는 TransportFailThreshold/TransportProbeInterval 과 달리 테넌트별로 노출되어 있지 않습니다.

레이트리밋 (§8.2)

share = transport.RatePerSecond / 활성 sender 레플리카 수. 레플리카 수는 하트비트(10s)로 갱신되므로 스케일 변화에 1주기 내로 수렴합니다. 두 버킷(transport, (transport, 수신 도메인))을 같은 시각으로 전진시켜 한쪽이 기다리는 동안 다른 쪽 토큰을 태우지 않습니다. rate_limited 응답 → 해당 버킷 절반(하한 share/64), 이후 분당 +10% 로 share까지 회복(AIMD).

루트 패키지와의 연결

이 패키지는 sendplane 루트를 import하지 않습니다(루트의 RunSender 가 여기를 호출하므로 사이클이 됩니다). 전에는 그래서 Hooks / OutboundMessage / RecipientContext / SecretCipher / ErrSkip / Metrics 를 여기에 복제해 두고 루트가 어댑터를 썼지만, 지금은 전부 leaf 패키지 host에 있습니다(architecture §2.1). 루트가 그 타입들을 별칭으로 재노출하므로 어댑터가 없습니다 — 변환도, 복사도, ErrSkip 번역도 없습니다:

snd, err := sender.New(s.opts.Store, sender.Config{
    WorkerID: c.WorkerID,
    Hooks:    s.opts.Hooks,   // sendplane.Hooks == host.Hooks
    Secrets:  s.opts.Secrets,
    Metrics:  s.opts.Metrics,
})

metrics.go 에는 메트릭 이름 상수만 남았습니다. 싱크 인터페이스(Count/Observe)는 host.Metrics 입니다 — Config.Metrics 가 루트의 Options.Metrics 로 그대로 채워지므로, 여기에 선언해 두면 루트가 타입 하나 때문에 internal/sender 를 import해야 합니다.

라이브러리

용도 선택 메모
MIME 빌더 + SMTP 클라이언트 github.com/wneessen/go-mail v0.8.1 메시지는 mail.Msg 로 만들고 WriteTo 로 바이트를 얻은 뒤 go-mail/smtp 클라이언트로 직접 MAIL/RCPT/DATA 합니다. mail.Client 를 쓰지 않는 이유: envelope sender(VERP)와 커넥션 수명·재사용을 sender가 직접 통제해야 하고, DKIM 서명을 완성된 바이트에 적용해야 하기 때문
DKIM github.com/emersion/go-msgauth/dkim v0.7.0 dkim.Sign(w, r, opts), relaxed/relaxed, RSA·Ed25519(PKCS#1/PKCS#8 PEM)

Documentation

Overview

Package sender is the claim → render → SMTP loop of architecture 8.

One Sender is one replica. It polls the delivery queue per tenant and lane (ADR-0002), renders each delivery for its recipient (internal/render), applies the tracking and unsubscribe transforms of architecture 9.2, builds the MIME message of architecture 10, and hands it to a pooled SMTP connection. SMTP results are normalized into the five error classes of architecture 4.2 and committed in batches, with an immediate MarkSent right after 250 so that a crash before the batch commits costs a duplicate at most, never a lost send.

The package does not import the root sendplane package: the root package is what calls Run. The types the host injects (Hooks, OutboundMessage, SecretCipher, Metrics) come from the leaf package host instead, which the root re-exports as aliases, so Config is filled straight from Options with no adapter. See README.

Index

Constants

View Source
const (
	// HeaderSendplaneID correlates a DSN with a delivery even when the relay
	// rewrote the envelope sender.
	HeaderSendplaneID = "X-Sendplane-ID"
	// HeaderAttempt carries the attempt number. It makes a retry visible in a
	// relay's logs, and it is what chaossmtp keys its deterministic failures
	// on.
	HeaderAttempt = "X-Sendplane-Attempt"
	// HeaderProbe carries "{run_id}/{hmac}" on a lane=probe message. It is
	// what the loopback collector searches the probe mailbox by (IMAP SEARCH
	// HEADER), and the HMAC is what keeps a mail somebody else dropped in that
	// mailbox from producing a verdict (architecture 11.2, ADR-0012).
	HeaderProbe = "X-Sendplane-Probe"
)

sendplane headers (architecture 10).

View Source
const (
	MetricClaimed     = "sendplane.sender.claimed"
	MetricProcessed   = "sendplane.sender.processed"
	MetricRenderTime  = "sendplane.sender.render.seconds"
	MetricSendTime    = "sendplane.sender.smtp.seconds"
	MetricLimiterWait = "sendplane.sender.limiter.wait.seconds"
	MetricConnOpened  = "sendplane.sender.conn.opened"
	MetricConnClosed  = "sendplane.sender.conn.closed"
	MetricTransport   = "sendplane.sender.transport.status"
)

Metric names. They are constants so that a dashboard and a test refer to the same string.

The sink itself is host.Metrics (Count/Observe), which lives in the host package because Config.Metrics is filled straight from the root package's Options: a metrics interface declared here would force the root package to import internal/sender just for a type.

View Source
const (
	EventDeliverySent   = "delivery.sent"
	EventDeliveryFailed = "delivery.failed"
)

The per-delivery event types the send path emits (architecture 12). They are written to the same store as the result they describe, right after Complete committed it, which is the outbox pattern the campaign and bounce events already use.

Both are off unless the tenant subscribes to them, and delivery.sent is off even in the default set: it is one outbox row, one dispatch and one HTTP POST per recipient, so a million-recipient campaign would produce a million of each (store.DefaultOffEventTypes).

View Source
const (
	DefaultClaimBatch             = 64
	DefaultLeaseFor               = 2 * time.Minute
	DefaultPollInterval           = 200 * time.Millisecond
	DefaultCampaignRefresh        = 3 * time.Second
	DefaultTenantConcurrency      = 64
	DefaultTenantCacheTTL         = 5 * time.Second
	DefaultHeartbeatInterval      = 10 * time.Second
	DefaultWorkerTTL              = 30 * time.Second
	DefaultResultBatchSize        = 100
	DefaultResultFlushInterval    = time.Second
	DefaultRenderTimeout          = 10 * time.Second
	DefaultTransportFailThreshold = 3
	DefaultTransportProbeInterval = time.Minute
)

Default configuration values (architecture 8).

Variables

View Source
var (
	// ErrHeaderInjection is returned when an address, subject or header value
	// carries CR or LF (architecture 16).
	ErrHeaderInjection = errors.New("sender: header injection")
	// ErrHeaderNotAllowed is returned for a custom header outside the
	// allowlist.
	ErrHeaderNotAllowed = errors.New("sender: header not allowed")
	// ErrNoBody is returned when neither an HTML nor a text part rendered.
	ErrNoBody = errors.New("sender: message has no body")
)

Message errors.

View Source
var ErrPoolClosed = errors.New("sender: connection pool closed")

ErrPoolClosed is returned by Get after Close.

Functions

func FailureReason

func FailureReason(shared bool, reason string) string

FailureReason is what a tenant may be told about why a send could not go out: the real reason for its own resources, the generic one for a shared resource.

func Reusable

func Reusable(f Failure) bool

Reusable reports whether a connection survives a failure: a normal SMTP rejection leaves the session usable, a 421 or a network error does not.

func SharedSenderReason

func SharedSenderReason() string

SharedSenderReason is the generic reason string, exported so that the HTTP layer says exactly the same thing on campaign start.

func ValidateCustomHeader

func ValidateCustomHeader(name, value string) error

ValidateCustomHeader is the whitelist check of architecture 16, applied to every caller-supplied header: a Hooks.BeforeSend addition, and the `headers` of POST /messages that store.Delivery.Headers carries.

It is exported so that the HTTP layer can refuse a disallowed name at 422 instead of storing a header that would fail the delivery at send time. A header that passes here is one the sender will actually put on the wire.

Types

type Config

type Config struct {
	// WorkerID identifies this replica. It is the lease owner on claimed
	// deliveries and the ID of its Workers heartbeat row, so it must be stable
	// for the life of the process and unique in the cluster.
	WorkerID string

	// Lanes is the worker pool size per lane. Transactional is always served
	// before bulk. Default: 8 transactional, 32 bulk.
	Lanes map[store.Lane]int
	// ClaimBatch is the maximum number of deliveries claimed in one call.
	ClaimBatch int
	// LeaseFor is how long a claim holds a delivery (architecture 8.1).
	LeaseFor time.Duration
	// PollInterval is how long the loop sleeps after a pass that claimed
	// nothing.
	PollInterval time.Duration
	// CampaignRefresh is the TTL of the running-campaign set.
	CampaignRefresh time.Duration
	// TenantConcurrency caps the deliveries one tenant may occupy at once, so
	// one big campaign cannot starve the others.
	TenantConcurrency int
	// TenantCacheTTL is the TTL of cached tenant configuration rows.
	TenantCacheTTL time.Duration

	// HeartbeatInterval is how often the Workers row is refreshed, WorkerTTL
	// how far back ListActive looks. The active replica count divides the
	// cluster-wide transport rate (architecture 8.2).
	HeartbeatInterval time.Duration
	WorkerTTL         time.Duration
	// DefaultRatePerSecond applies to transports that configure no rate. Zero
	// means unlimited.
	DefaultRatePerSecond float64

	// ResultBatchSize and ResultFlushInterval drive the batched Complete.
	ResultBatchSize     int
	ResultFlushInterval time.Duration

	// Connection pool.
	MaxMsgsPerConn  int
	ConnIdleTimeout time.Duration
	DialTimeout     time.Duration
	SendTimeout     time.Duration
	EHLOName        string
	TLSConfig       *tls.Config
	Dialer          DialFunc

	// RenderTimeout bounds one recipient's Liquid render.
	RenderTimeout time.Duration

	// TransportFailThreshold is how many consecutive auth/TLS/connect failures
	// mark a transport unhealthy; TransportProbeInterval how often an unhealthy
	// one is probed (architecture 8.3).
	TransportFailThreshold int
	TransportProbeInterval time.Duration

	// RateLimitCap and AuthRetryAfter are the policy knobs of policy.go.
	RateLimitCap   time.Duration
	AuthRetryAfter time.Duration

	Hooks    host.Hooks
	Secrets  host.SecretCipher
	Renderer *render.Renderer
	// Platform resolves the shared senders of the operator's platform catalog:
	// their templated From addresses, the uses they are allowed for, and the
	// per-tenant rate share of the transport behind them (ADR-0017). Nil in a
	// deployment with no platform resources, which is every single-tenant one.
	Platform *platform.Resolver
	Metrics  host.Metrics
	Logger   *slog.Logger
	Clock    func() time.Time
	// Rand is the jitter source; nil uses math/rand/v2.
	Rand func() float64
}

Config configures one sender replica.

type Conn

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

Conn is a checked-out SMTP connection.

func (*Conn) Send

func (c *Conn) Send(from string, rcpts []string, data []byte, timeout time.Duration) error

Send runs one MAIL/RCPT/DATA transaction. The returned error is whatever the server or the network produced; Classify turns it into an error class.

func (*Conn) Sent

func (c *Conn) Sent() int

Sent reports how many messages this connection has accepted.

type Decision

type Decision struct {
	Status        store.DeliveryStatus
	NextAttemptAt time.Time
	// IncrementAttempt is true for transient and rate_limited only. Transport
	// faults (auth, TLS) are recorded but must not exhaust a delivery.
	IncrementAttempt bool
	// RecordAttempt is false when nothing was attempted over SMTP at all
	// (suppressed, skipped), so no DeliveryAttempt row is written.
	RecordAttempt bool
}

Decision is what happens to a delivery after one attempt: the status to commit, when it becomes eligible again, and whether this attempt consumes one of the retries (architecture 4.1, ADR-0003).

type DialFunc

type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)

DialFunc opens the TCP connection to a transport. Tests replace it.

type Failure

type Failure struct {
	Class store.ErrorClass
	// Code is the SMTP reply code, or 0 for a failure that never got one
	// (connection refused, timeout, TLS).
	Code int
	// Enhanced is the RFC 3463 status ("4.7.0"), when the reply carried one.
	Enhanced string
	// Message is the reply text, or the Go error text.
	Message string
	// Rule names the classification rule that matched, for logs and tests.
	Rule string
	Err  error
}

Failure is a normalized transport failure: the SMTP reply if there was one, and the error class that decides what happens next (architecture 4.2).

func Classify

func Classify(err error) Failure

Classify normalizes a send error into one of the five classes. A nil error is ErrorClassNone.

type Key

type Key struct {
	Name string
	Rate float64
}

Key is one bucket: a name and the cluster-wide rate configured for it. A zero or negative rate means unlimited and costs nothing.

type Limiter

type Limiter struct {
	// Workers reports the number of active sender replicas. It must return at
	// least 1; a nil func means "this replica is alone".
	Workers func() int
	// Now is the clock (tests inject one).
	Now func() time.Time
	// Sleep waits; tests replace it to avoid real time.
	Sleep func(ctx context.Context, d time.Duration) error
	// contains filtered or unexported fields
}

Limiter is the token bucket set of architecture 8.2.

A transport's configured RatePerSecond is a cluster-wide target. Each replica applies its own share of it - the target divided by the number of sender replicas that have a live heartbeat - so the cluster converges on the target without a central lock, within one heartbeat period of a replica count change.

On a rate_limited reply the bucket halves (Penalize) and then recovers by 10% per minute up to its share again: additive-increase / multiplicative-decrease, the same shape TCP uses.

func NewLimiter

func NewLimiter(workers func() int, now func() time.Time) *Limiter

NewLimiter returns a limiter. workers may be nil.

func (*Limiter) Penalize

func (l *Limiter) Penalize(names ...string)

Penalize halves the rate of every named bucket after a rate_limited reply.

func (*Limiter) Rate

func (l *Limiter) Rate(name string) float64

Rate reports a bucket's current applied rate; it exists for tests and metrics. It returns 0 for an unknown bucket.

func (*Limiter) Wait

func (l *Limiter) Wait(ctx context.Context, keys ...Key) (time.Duration, error)

Wait blocks until one token is available in every key's bucket, or the context is done. It returns the time spent waiting.

type Policy

type Policy struct {
	Retry store.RetryPolicy
	// RateLimitCap shortens the backoff for rate_limited: architecture 4.2
	// asks for "a short retry plus a transport-level slowdown", and the
	// slowdown (ratelimit.go) is what actually spaces the traffic out, so the
	// delivery itself should come back soon.
	RateLimitCap time.Duration
	// AuthRetryAfter is how long an auth-class failure parks the delivery. It
	// stays queued and does not consume an attempt, so without a delay the
	// same delivery would be re-claimed in a tight loop while the transport is
	// being probed.
	AuthRetryAfter time.Duration
	// Jitter is the fraction the backoff is randomized by, ±Jitter.
	Jitter float64
	// Rand returns a value in [0,1). nil uses an internal source.
	Rand func() float64
}

Policy turns an error class into a Decision. It holds the tenant retry policy plus the two knobs that are not tenant-configurable.

func DefaultPolicy

func DefaultPolicy(retry store.RetryPolicy) Policy

DefaultPolicy fills in the non-tenant knobs.

func (Policy) Decide

func (p Policy) Decide(class store.ErrorClass, attemptCount int, now time.Time) Decision

Decide applies the policy. attemptCount is the delivery's current AttemptCount, i.e. the number of retries already consumed.

type Pool

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

Pool holds one connection group per transport.

func NewPool

func NewPool(cfg PoolConfig) *Pool

NewPool returns a pool with defaults applied.

func (*Pool) Close

func (p *Pool) Close()

Close drops every idle connection. Connections still checked out are closed by their Put.

func (*Pool) CloseTransport

func (p *Pool) CloseTransport(id string)

CloseTransport drops every idle connection of a transport, used when it is marked unhealthy.

func (*Pool) Get

func (p *Pool) Get(ctx context.Context, t *store.Transport, password string) (*Conn, error)

Get checks out a connection for a transport, dialing if the pool has none that can be reused. password is the decrypted SMTP password.

func (*Pool) Put

func (p *Pool) Put(c *Conn, reusable bool)

Put returns a connection to the pool. reusable is false when the connection is in an unknown state (network error, 421); Reusable computes it from a classified failure.

type PoolConfig

type PoolConfig struct {
	// MaxMsgsPerConn reconnects after this many messages on one connection.
	// Long-lived connections accumulate per-connection limits on the far side
	// (architecture 8.1).
	MaxMsgsPerConn int
	// IdleTimeout closes a connection that has been idle this long.
	IdleTimeout time.Duration
	// DialTimeout bounds connect + greeting + EHLO + AUTH.
	DialTimeout time.Duration
	// SendTimeout bounds one MAIL/RCPT/DATA transaction.
	SendTimeout time.Duration
	// EHLOName is the name announced in EHLO.
	EHLOName string
	// TLSConfig is the base config for STARTTLS and implicit TLS; ServerName
	// is filled in from the transport host.
	TLSConfig *tls.Config
	// Dialer defaults to a net.Dialer.
	Dialer DialFunc
	Now    func() time.Time
	// Metrics is never nil after withDefaults.
	Metrics host.Metrics
}

PoolConfig configures Pool.

type Sender

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

Sender is one replica of the send loop.

func New

func New(p store.Provider, cfg Config) (*Sender, error)

New returns a Sender. It does not touch the store.

func (*Sender) Limiter

func (s *Sender) Limiter() *Limiter

Limiter exposes the rate limiter, for tests and metrics.

func (*Sender) Pool

func (s *Sender) Pool() *Pool

Pool exposes the connection pool, for tests and for a future admin route.

func (*Sender) Run

func (s *Sender) Run(ctx context.Context) error

Run claims and sends until ctx is done. It returns nil on a clean shutdown.

Jump to

Keyboard shortcuts

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