Documentation
¶
Overview ¶
Package grpop provides a send-only, multi-channel transactional-messaging delivery library for the gourdian ecosystem: email and WhatsApp dispatch, idempotent send processing, a hard-deadline dead-letter retry queue, circuit breaking, and per-channel-and-per-recipient distributed rate limiting, behind a set of storage- and vendor-agnostic interfaces.
grpop is not an email-retrieval/POP3 library, despite the name, and not a push-notification library — that is the sibling gourdian25 module grnoti's job. grpop has no concept of "user" or "preferences"; it is about delivery, not identity, and the recipient address/number is supplied per-send by the caller.
Package shape ¶
grpop's public API is a single flat package with no subpackages — every backend (PostgreSQL, MongoDB, Redis) and every vendor dispatcher (a stdlib net/smtp-based email sender, a hand-rolled Meta WhatsApp Cloud API client) lives in this one module, distinguished by file-naming convention: "<concern>.<backend>.go" for storage/rate-limiting (e.g. dlq.postgres.go, ratelimiter.redis.go), and "dispatcher.<channel>.<vendor>.go" for the two dispatch files (dispatcher.email.smtp.go, dispatcher.whatsapp.metacloud.go) — a third segment, since grpop's dispatch layer has a genuinely two-dimensional axis (channel x vendor) that grnoti's single-channel, single-vendor dispatch layer didn't need. The one exception is internal/postgresdb, sqlc's generated query code — a real Go subpackage, but an unexported internal/ one, not importable outside this module, so it doesn't undermine the "flat public API" claim.
This follows grnoti's and gourdiantoken's precedent rather than grcache's/graudit's subpackage-per-backend layout, which exists specifically to keep unused backend drivers out of a consumer's dependency graph. grpop's version of this tradeoff is unusually cheap compared to grnoti's own: grpop's dispatch layer has ZERO third-party Go dependencies at all (email goes over plain SMTP via the standard library; WhatsApp goes directly against Meta's Graph API over a hand-rolled net/http client, since Meta ships no official Go SDK) — the only third-party imports anywhere in this module are for its own storage/rate-limiting backends (pgx/v5, go-redis/v9, optionally mongo-driver) and grcache's/grevents' own lightweight root-interface packages. Importing grpop does not pull in any vendor-messaging SDK (no AWS SDK, no Twilio, no SendGrid) and no message-broker client library (no Kafka/NATS/RabbitMQ) regardless of which backends a given deployment actually uses. See docs/plan/grpop-plan.md §3 for the full reasoning.
Precise, non-aspirational claims ¶
SendStatusSent means "the vendor (SMTP relay, or Meta's Graph API) accepted the request" — never "arrived in a recipient's inbox" or "was read." grpop has no vendor-side bounce/complaint/delivery-receipt feedback for either channel in this version; a SendStatusSent result is not proof of actual delivery.
grpop's inline retry (and any later DLQ-driven retry) is at-least-once, not exactly-once: a network error after a vendor has already accepted a send is indistinguishable, from grpop's side, from an error before acceptance, so a retry can re-attempt a send the vendor already processed. This is why SendOptions.IdempotencyKey is required, not optional — the guarantee against a caller-visible double-send comes entirely from the caller supplying a stable key and IdempotencyStore catching the redelivery, not from any property of the send path itself.
There is no message broker or queue anywhere in this package. Every Service.SendX call dispatches directly and synchronously to the vendor on the caller's own goroutine; DLQHandler's Postgres/Mongo table is a durable, pull-based retry store a consuming application's own worker polls (via ClaimRetryableEvents), not a transport mechanism a send passes through on its way out.
DLQStatusExpired (a DLQEvent's terminal state once its ExpiresAt deadline passes without a successful retry) is unrelated to DLQHandler.PurgeExpiredEvents' use of "expired," despite the shared word — PurgeExpiredEvents' sense of "expired" means "old enough to delete" and applies to Resolved/Exhausted/Expired events alike after maxAge; DLQStatusExpired means "gave up retrying because the message's own deadline passed," independent of how long the row has existed.
grpop never refreshes or rotates a WhatsApp dispatcher's Meta access token. Token lifecycle — rotating a long-lived/System User token before it expires — is entirely the operator's responsibility; grpop only ever uses whatever token it was constructed with.
grpop_dlq (the Postgres DLQHandler's table) stores each failed send's full message payload — including any secrets it carries, such as a password-reset link or invite token — in grpop_dlq.message_data, in the clear, unless a MessageEncryptor is configured. Absent one, grpop_dlq must be operated with the same access-control rigor as a credentials table: network-isolated, RBAC'd, not queryable by anyone who would not already be trusted with the secrets it can contain.
TemplateValidator's approval cache means "approved as of the last check," not a live guarantee — Meta can approve, reject, or delete a template between grpop's last cache refresh and a live send. The underlying vendor call remains the ultimate source of truth: a passed TemplateValidator check never causes Send to skip actually calling Meta.
Index ¶
- Constants
- Variables
- func FullJitterBackoff(base, max time.Duration, attempt int) time.Duration
- func PublishMessageFailed(ctx context.Context, bus grevents.Bus, logger Logger, ...)
- func PublishMessageSent(ctx context.Context, bus grevents.Bus, logger Logger, ...)
- type Channel
- type CircuitBreaker
- type CircuitBreakerConfig
- type CircuitBreakerStats
- type CircuitState
- type DLQEvent
- type DLQHandler
- type DLQMessage
- type DLQRetryAttempt
- type DLQStatus
- type EmailMessage
- type EmailSender
- type EmailTemplate
- type EmailTemplateEngine
- type EmailTemplateEngineConfig
- type IdempotencyStore
- type Logger
- type MemoryEmailSender
- type MemoryWhatsAppSender
- type MessageEncryptor
- type MessageFailedPayload
- type MessageSentPayload
- type MetaCloudDispatcherDeps
- type MetaTemplateValidatorDeps
- type Metrics
- type MongoDLQHandlerConfig
- type PostgresConfig
- type PostgresDLQHandlerConfig
- type RateLimiter
- type RateLimiterStats
- type RedisRateLimiterConfig
- type SMTPClient
- type SMTPDialer
- type SMTPDispatcherDeps
- type SMTPTLSMode
- type SendOptions
- type SendResult
- type SendStatus
- type Service
- type ServiceConfig
- type ServiceDeps
- type TemplateValidator
- type WhatsAppCloudAPIClient
- type WhatsAppCloudAPIRequest
- type WhatsAppCloudAPIResponse
- type WhatsAppMessage
- type WhatsAppSender
- type WhatsAppTemplateInfo
Constants ¶
const ( // TopicMessageSent fires after Service's own inline retry succeeds. TopicMessageSent = "message.sent" // TopicMessageFailed fires after Service's inline retry is exhausted // and the event has (successfully or not) been handed to DLQHandler. TopicMessageFailed = "message.failed" )
Topic* are the grevents topics Service publishes to — the only "broker-shaped" thing in grpop, and one-directional/observability-only, never a substitute for an actual message-broker-mediated send path (see docs.go). Deliberately carry Channel + SendID only, never the recipient address/number or any message content: an event bus is often subscribed to broadly within a consuming application, and grpop's payloads themselves are frequently security-sensitive (reset links, invite tokens) — see MessageEncryptor's own doc comment for the same class of concern applied to durable storage instead of this side channel.
const DefaultDLQCollection = "grpop_dlq"
DefaultDLQCollection is the collection name used when MongoDLQHandlerConfig.CollectionName is empty.
Variables ¶
var ( // ErrClosed indicates a method was called after Close. ErrClosed = errors.New("grpop: closed") // connection could not be reached (connection failure, timeout, etc.). ErrBackendUnavailable = errors.New("grpop: backend unavailable") // ErrIdempotencyKeyRequired indicates SendOptions.IdempotencyKey was // empty. Required, not optional (docs.go, plan §9 item 8): grpop's // inline retry is at-least-once, not exactly-once, so the guarantee // against a caller-visible double-send comes entirely from a caller- // supplied stable key and IdempotencyStore catching the redelivery. ErrIdempotencyKeyRequired = errors.New("grpop: idempotency key is required") // ErrRecipientRequired indicates EmailMessage.To or WhatsAppMessage.To // was empty. ErrRecipientRequired = errors.New("grpop: recipient is required") // ErrNoContentModeSet indicates an EmailMessage has none of // TemplateName, InlineTemplate, or a literal Subject/HTMLBody/TextBody // set — there is no content to send. ErrNoContentModeSet = errors.New("grpop: email message has no content mode set (TemplateName, InlineTemplate, or literal body)") // ErrMultipleContentModesSet indicates an EmailMessage has more than // one of TemplateName, InlineTemplate, and a literal Subject/HTMLBody/ // TextBody set — grpop does not guess which one wins, the caller must // pick exactly one. ErrMultipleContentModesSet = errors.New("grpop: email message has more than one content mode set (TemplateName, InlineTemplate, literal body are mutually exclusive)") // ErrEmailTemplateNotFound indicates EmailTemplateEngine.Render was // called with a name no RegisterTemplate call has registered. ErrEmailTemplateNotFound = errors.New("grpop: email template not found") // ErrInlineTemplateTooLarge indicates an EmailMessage.InlineTemplate's // combined template source exceeds // EmailTemplateEngineConfig.MaxInlineTemplateBytes. RenderInline has no // caching to amortize an oversized template's compile cost the way // Render does for a registered one, so this is enforced up front. ErrInlineTemplateTooLarge = errors.New("grpop: inline template exceeds maximum size") // ErrWhatsAppTemplateNameRequired indicates WhatsAppMessage.TemplateName // was empty. ErrWhatsAppTemplateNameRequired = errors.New("grpop: whatsapp template name is required") // ErrWhatsAppLanguageCodeRequired indicates WhatsAppMessage.LanguageCode // was empty. ErrWhatsAppLanguageCodeRequired = errors.New("grpop: whatsapp language code is required") // ErrWhatsAppTemplateNotApproved indicates TemplateValidator.Validate // found no currently-approved Meta template matching the requested // name+language. ErrWhatsAppTemplateNotApproved = errors.New("grpop: whatsapp template is not approved") // ErrWhatsAppTemplateArityMismatch indicates TemplateValidator.Validate // found an approved template matching name+language, but // len(TemplateVariables) does not match its expected parameter count. ErrWhatsAppTemplateArityMismatch = errors.New("grpop: whatsapp template variable count does not match the approved template's expected parameter count") // ErrRateLimited indicates RateLimiter.Allow reported no token // available (per-channel or per-recipient bucket exhausted) for a // Send call that did not opt into SendOptions.SkipRateLimit. ErrRateLimited = errors.New("grpop: rate limited") // ErrDLQEventNotFound indicates a DLQHandler lookup found no DLQEvent // for the requested send ID. ErrDLQEventNotFound = errors.New("grpop: dead-letter event not found") // ErrDLQEventNotClaimed indicates MarkRetried was called for an event // that is not currently in the "retrying" (claimed) state — either it // was never claimed via ClaimRetryableEvents, already resolved/ // exhausted/expired, or claimed by a concurrent caller. ErrDLQEventNotClaimed = errors.New("grpop: dead-letter event is not in a claimed (retrying) state") // ErrEmailFromRequired indicates both EmailMessage.From and the // dispatcher's configured DefaultFrom were empty — SMTP requires a MAIL // FROM address, and grpop does not invent one. ErrEmailFromRequired = errors.New("grpop: email From address is required (set EmailMessage.From or SMTPDispatcherDeps.DefaultFrom)") // ErrEmailTemplateEngineRequired indicates an EmailMessage used // TemplateName or InlineTemplate but the dispatcher has no // EmailTemplateEngine configured to render it. ErrEmailTemplateEngineRequired = errors.New("grpop: email message uses TemplateName/InlineTemplate but no EmailTemplateEngine is configured") )
Sentinel errors for use with errors.Is. Backend implementations translate their own native errors (pgx.ErrNoRows, redis.Nil, mongo.ErrNoDocuments, a vendor SDK's own error type, ...) into these sentinels before wrapping with fmt.Errorf("...: %w", ...) — a backend-native error must never leak through a grpop interface unwrapped, matching grcache's, graudit's, and grnoti's own documented rule.
There is deliberately no IsX(err error) bool helper: callers use errors.Is(err, grpop.ErrClosed) directly, consistent with every other gourdian repo's sentinel-error convention.
Distinct conditions get distinct sentinels rather than being reused across unrelated cases — see docs/plan/grpop-plan.md §2 (of grnoti's own plan, the precedent this follows) for the class of bug that reusing one sentinel for two different meanings causes. Two examples here: ErrNoContentModeSet and ErrMultipleContentModesSet are separate sentinels for opposite conditions on EmailMessage's three mutually-exclusive content modes, not one generic "invalid content mode" error; likewise ErrWhatsAppTemplateNotApproved and ErrWhatsAppTemplateArityMismatch are the two distinct reasons TemplateValidator.Validate can fail.
var ErrCircuitOpen = errors.New("grpop: circuit breaker is open")
ErrCircuitOpen is returned by CircuitBreaker.Execute when the breaker is open and its Timeout has not yet elapsed.
var ErrTooManyRequests = errors.New("grpop: too many requests while circuit breaker is half-open")
ErrTooManyRequests is returned by CircuitBreaker.Execute when the breaker is half-open and MaxHalfOpenRequests trial requests are already in flight.
var Version = "v0.1.0"
Version is the semantic version of this module, matching its most recent git tag.
Functions ¶
func FullJitterBackoff ¶
FullJitterBackoff returns a randomized backoff duration for the given 0-indexed attempt that just failed: sleep = random(0, min(cap, base*2^attempt)) — the AWS "Full Jitter" formula. This mirrors grevents' retry.go computeBackoff and grnoti's own FullJitterBackoff exactly, deliberately kept identical rather than inventing a third copy of the same formula. It is exported so it can be shared by both Service's inline per-call retry (docs.go, "no message broker") and the Postgres/Mongo DLQHandler backends' own NextRetryAt computation (Stage 6/7), instead of two independently-written copies.
Parameters:
- base: time.Duration — the starting point; base<=0 returns 0 (no backoff)
- max: time.Duration — the ceiling; max<=0 defaults to defaultMaxBackoff
- attempt: int — 0-indexed attempt number
Returns:
- time.Duration: a value in [0, min(max, base*2^attempt)]
func PublishMessageFailed ¶
func PublishMessageFailed(ctx context.Context, bus grevents.Bus, logger Logger, payload MessageFailedPayload)
PublishMessageFailed publishes a TopicMessageFailed event. See PublishMessageSent for the nil-bus/best-effort contract.
func PublishMessageSent ¶
func PublishMessageSent(ctx context.Context, bus grevents.Bus, logger Logger, payload MessageSentPayload)
PublishMessageSent publishes a TopicMessageSent event for payload via bus. Following grnoti's own PublishSent/graudit's PublishRecorded precedent: bus may be nil (a silent no-op), and any error bus.Publish returns is only logged, never propagated to the caller — grevents delivery is a best-effort side channel on top of whatever durable/ authoritative work already happened (the actual send), never allowed to fail or block it.
Types ¶
type Channel ¶
type Channel string
Channel identifies which delivery channel a message/send/rate-limit bucket applies to.
type CircuitBreaker ¶
type CircuitBreaker interface {
// Execute runs fn if the breaker's current state allows it.
//
// Returns:
// - error: ErrCircuitOpen if the breaker is open and its Timeout
// hasn't elapsed; ErrTooManyRequests if half-open and
// MaxHalfOpenRequests trial requests are already in flight;
// otherwise fn's own return value
Execute(ctx context.Context, fn func() error) error
State() CircuitState
GetStats() CircuitBreakerStats
// Reset forces the breaker back to CircuitStateClosed, for
// administrative use.
Reset()
}
CircuitBreaker wraps calls to an unreliable dependency (an SMTP relay, or Meta's Graph API) so persistent failures stop being retried immediately and instead fail fast for a cooldown period. One instance per dispatcher, not shared/centralized across replicas.
func NewCircuitBreaker ¶
func NewCircuitBreaker(maxFailures int, timeout, resetTimeout time.Duration) (CircuitBreaker, error)
NewCircuitBreaker constructs a CircuitBreaker with MaxHalfOpenRequests fixed at 1. One instance is meant per dispatcher (one for dispatcher.email.smtp.go, one for dispatcher.whatsapp.metacloud.go), not shared/centralized across replicas.
Parameters:
- maxFailures: int — consecutive failures before opening; must be > 0
- timeout: time.Duration — how long to stay open before allowing a trial request; must be > 0
- resetTimeout: time.Duration — how long a closed breaker must go without a failure before its consecutive-failure counter resets; must be > 0
Returns:
- CircuitBreaker
- error: non-nil if any parameter is not positive
func NewCircuitBreakerWithConfig ¶
func NewCircuitBreakerWithConfig(config CircuitBreakerConfig) (CircuitBreaker, error)
NewCircuitBreakerWithConfig constructs a CircuitBreaker from a full CircuitBreakerConfig.
Parameters:
- config: CircuitBreakerConfig — MaxFailures/Timeout/ResetTimeout must each be > 0; MaxHalfOpenRequests defaults to 1 if <= 0
Returns:
- CircuitBreaker
- error: non-nil if MaxFailures/Timeout/ResetTimeout is not positive
type CircuitBreakerConfig ¶
type CircuitBreakerConfig struct {
// MaxFailures is the number of consecutive failures that trips the
// breaker from closed to open.
MaxFailures int
// Timeout is how long the breaker stays open before allowing a trial
// request through (transitioning to half-open).
Timeout time.Duration
// ResetTimeout is how long a closed breaker must go without a failure
// before its consecutive-failure counter resets to zero.
ResetTimeout time.Duration
// MaxHalfOpenRequests bounds concurrent trial requests while
// half-open. Defaults to 1 if <= 0.
MaxHalfOpenRequests int
// Logger receives optional diagnostic messages for state transitions
// (open/half-open/close). A nil Logger disables logging.
Logger Logger
}
CircuitBreakerConfig configures a CircuitBreaker.
type CircuitBreakerStats ¶
type CircuitBreakerStats struct {
State CircuitState
ConsecutiveFailures int
TotalSuccesses int64
TotalFailures int64
TotalRejections int64
LastFailureTime time.Time
LastStateChange time.Time
OpenedAt time.Time
TimeUntilNextAttempt time.Duration
}
CircuitBreakerStats is a point-in-time snapshot of a CircuitBreaker's counters.
type CircuitState ¶
type CircuitState string
CircuitState is a CircuitBreaker's current state.
const ( // CircuitStateClosed is the normal state: requests pass through, and // consecutive failures are counted toward MaxFailures. CircuitStateClosed CircuitState = "closed" // CircuitStateOpen rejects every request immediately (without // attempting them) until Timeout elapses, then transitions to // CircuitStateHalfOpen. CircuitStateOpen CircuitState = "open" // CircuitStateHalfOpen allows up to MaxHalfOpenRequests trial requests // through to test whether the failing dependency has recovered: the // first trial failure trips it straight back to CircuitStateOpen, the // first trial success closes it. CircuitStateHalfOpen CircuitState = "half_open" )
type DLQEvent ¶
type DLQEvent struct {
SendID string
MessageData DLQMessage // carries Channel and ExpiresAt
FailureReason string
RetryCount int
MaxRetries int
FirstFailureAt time.Time
LastAttemptAt time.Time
NextRetryAt time.Time
Status DLQStatus
AttemptHistory []DLQRetryAttempt // capped at a configured maximum entry count, oldest dropped first
CreatedAt time.Time
UpdatedAt time.Time
}
DLQEvent is the durable record of one failed send, awaiting retry or already Resolved/Exhausted/Expired.
type DLQHandler ¶
type DLQHandler interface {
PublishToDLQ(ctx context.Context, sendID string, msg DLQMessage, failureReason string) error
// ClaimRetryableEvents does two things, in order, each call:
//
// 1. Proactively transitions any DLQStatusPending event whose
// msg.ExpiresAt has already passed to DLQStatusExpired — a plain
// bulk update, not part of the atomic-claim step below, since it
// needs no cross-replica coordination. Without this step, an event
// whose deadline passes while nothing calls ClaimRetryableEvents
// for it would sit invisibly in Pending forever instead of
// surfacing as a terminal, dashboard-visible state.
//
// 2. Atomically selects up to limit of the remaining events whose
// NextRetryAt has passed, Status is still DLQStatusPending, and
// ExpiresAt is still in the future, transitioning each to
// DLQStatusRetrying as part of the same operation — so N
// concurrent worker replicas each claim disjoint events.
ClaimRetryableEvents(ctx context.Context, limit int) ([]*DLQEvent, error)
// MarkRetried records a retry attempt's outcome and transitions sendID
// out of DLQStatusRetrying: to DLQStatusResolved on success; to
// DLQStatusExpired if the recomputed NextRetryAt (on a failure with
// retries remaining) would land at or past msg.ExpiresAt — this check
// runs before the MaxRetries check, so a message that expires with
// retry budget still unused is reported as Expired, not Exhausted; to
// DLQStatusExhausted if RetryCount reaches MaxRetries (and ExpiresAt
// hasn't passed); otherwise back to DLQStatusPending with the
// recomputed NextRetryAt. Returns ErrDLQEventNotClaimed if sendID is
// not currently DLQStatusRetrying.
MarkRetried(ctx context.Context, sendID string, success bool, attemptErr error) error
GetEventByID(ctx context.Context, sendID string) (*DLQEvent, error)
// PurgeExpiredEvents deletes DLQStatusResolved/DLQStatusExhausted/
// DLQStatusExpired events, and any event older than maxAge regardless
// of status. Its name refers to a different sense of "expired" (old
// enough to clean up) than DLQStatusExpired (gave up retrying) —
// despite the naming overlap, the two are unrelated: an event can be
// DLQStatusExpired for a long time before PurgeExpiredEvents(ctx,
// maxAge) actually deletes its row.
PurgeExpiredEvents(ctx context.Context, maxAge time.Duration) (int64, error)
Close() error
}
DLQHandler is the durable, pull-based retry store for sends whose inline retry was exhausted. There is no background reclaim loop inside grpop itself — ClaimRetryableEvents is a primitive a consuming application's own periodic worker/cron calls. This is the closest thing to a "queue" that exists in grpop: nothing pushes a claimed event anywhere, a caller's own process polls for work.
func NewMemoryDLQHandler ¶
func NewMemoryDLQHandler(maxRetries int, retryDelay, maxRetryDelay time.Duration, maxAttemptHistory int) DLQHandler
NewMemoryDLQHandler constructs an in-memory DLQHandler.
Parameters:
- maxRetries: int — defaults to 3 if <= 0
- retryDelay, maxRetryDelay: time.Duration — passed to FullJitterBackoff for computing each event's NextRetryAt; unlike maxRetries, 0 is a valid, deliberate choice here (immediate retry-eligibility, useful for tests), not silently replaced with a default
- maxAttemptHistory: int — caps DLQEvent.AttemptHistory, oldest entry dropped first once exceeded; defaults to 20 if <= 0
func NewMongoDLQHandler ¶
func NewMongoDLQHandler(cfg MongoDLQHandlerConfig) (DLQHandler, error)
NewMongoDLQHandler connects to MongoDB per cfg, ensures indexes (including a 7-day TTL index on created_at as a durable-retention backstop independent of PurgeExpiredEvents — relevant here specifically because grpop_dlq can hold sensitive payloads, see docs.go), and validates connectivity before returning.
Claim semantics: unlike a naive read-then-write, every mutating operation here is scoped by an atomic MongoDB operation — ClaimRetryableEvents uses FindOneAndUpdate per document (atomic per-document claim, no transaction needed), and MarkRetried's retry_count increment is a $inc scoped to {send_id, status: "retrying"} rather than a Go-side read-then-set.
func NewPostgresDLQHandler ¶
func NewPostgresDLQHandler(cfg PostgresDLQHandlerConfig) (DLQHandler, error)
NewPostgresDLQHandler connects per cfg.
Claim semantics: ClaimRetryableEvents runs an expiry sweep (a plain UPDATE) followed by a single UPDATE statement whose subquery uses SELECT ... FOR UPDATE SKIP LOCKED to let N concurrent callers each claim a disjoint batch of pending events without contention — see interfaces.go's DLQHandler doc comment and internal/postgresdb/queries/dlq.sql for the full two-statement sequence.
type DLQMessage ¶
type DLQMessage struct {
Channel Channel
Email *EmailMessage
WhatsApp *WhatsAppMessage
// ExpiresAt is the hard wall-clock deadline after which this event
// stops being retried at all, regardless of remaining RetryCount
// budget — set by Service from SendOptions.RetryExpiresAt or the
// configured default max retry age before PublishToDLQ is called.
ExpiresAt time.Time
}
DLQMessage is a channel-tagged envelope — exactly one of Email/WhatsApp is non-nil, matching Channel.
type DLQRetryAttempt ¶
type DLQRetryAttempt struct {
AttemptNumber int
AttemptedAt time.Time
Success bool
ErrorMessage string
}
DLQRetryAttempt records the outcome of one retry attempt for a DLQEvent.
type DLQStatus ¶
type DLQStatus string
DLQStatus is a DLQEvent's lifecycle state.
const ( // DLQStatusPending is newly-recorded or awaiting its next retry. DLQStatusPending DLQStatus = "pending" // DLQStatusRetrying means a worker currently holds an atomic claim on // this event (see DLQHandler.ClaimRetryableEvents) and is attempting // delivery — not a status any caller sets directly. DLQStatusRetrying DLQStatus = "retrying" // DLQStatusResolved means a retry eventually succeeded. DLQStatusResolved DLQStatus = "resolved" // DLQStatusExhausted means RetryCount reached MaxRetries before // ExpiresAt passed. DLQStatusExhausted DLQStatus = "exhausted" // DLQStatusExpired means ExpiresAt passed before a successful retry, // independent of remaining RetryCount budget. Unrelated to // DLQHandler.PurgeExpiredEvents' own, different sense of "expired" // (old enough to delete) — see that method's doc comment. DLQStatusExpired DLQStatus = "expired" )
type EmailMessage ¶
type EmailMessage struct {
To string // single recipient address, required
From string // optional; empty uses the dispatcher's configured default sender
ReplyTo string // optional
Subject string
HTMLBody string
TextBody string // optional plain-text alternative part
TemplateName string
InlineTemplate *EmailTemplate
TemplateData map[string]any
}
EmailMessage is an email with exactly one of three mutually-exclusive content modes set (see ErrNoContentModeSet, ErrMultipleContentModesSet):
- TemplateName (+ TemplateData) — renders via a template already registered with EmailTemplateEngine.RegisterTemplate.
- InlineTemplate (+ TemplateData) — a caller-supplied custom template, rendered on the fly via EmailTemplateEngine.RenderInline without requiring prior registration. This is the "pass a custom template at send time" path — for content not known ahead of RegisterTemplate time, e.g. per-tenant custom branding on an invite email.
- Literal Subject/HTMLBody/TextBody — no templating at all, used verbatim.
type EmailSender ¶
type EmailSender interface {
Send(ctx context.Context, msg EmailMessage) (SendResult, error)
Close() error
}
EmailSender dispatches a single EmailMessage. Kept separate from WhatsAppSender rather than unified behind one Send(ctx, msg Message) method — WhatsAppMessage's shape is not compatible with EmailMessage's without losing the compile-time template-vs-freeform-body distinction that is the whole point of the two message types.
func NewDryRunEmailSender ¶
func NewDryRunEmailSender(logger Logger) EmailSender
NewDryRunEmailSender returns an EmailSender that never contacts a real vendor: it logs recipient, channel, and template name (or "literal-body"/ "inline-template" if no TemplateName was set) at Info level, and returns a synthetic SendResult{Status: SendStatusSent, ProviderMessageID: "dryrun-<generated-id>"}.
It deliberately does NOT log the rendered Subject/HTMLBody/TextBody or TemplateData — staging environments still write to shared log aggregation, and grpop's actual payloads are security-sensitive (password-reset links, invite tokens); logging a fully-rendered dry-run message would leak exactly the secret grpop exists to deliver into every staging log for however long retention lasts.
A construction-time swap (pick this instead of NewSMTPDispatcher when building a staging ServiceDeps), not a runtime toggle inside the real dispatcher — kept deliberately simple.
Distinct from MemoryEmailSender (memory.go): that one exists for contract/unit tests and deliberately DOES record full sends for assertion (test-only, never wired into a shared log sink); this one exists for a staging/pre-prod deployment that should never actually deliver mail but should otherwise exercise the real Service pipeline.
func NewSMTPDispatcher ¶
func NewSMTPDispatcher(deps SMTPDispatcherDeps) (EmailSender, error)
NewSMTPDispatcher constructs an EmailSender that delivers over SMTP.
Parameters:
- deps: SMTPDispatcherDeps — deps.Addr is required
Returns:
- EmailSender
- error: non-nil if deps.Addr is empty/unparseable, or if deps.Auth is set alongside TLSMode == SMTPTLSInsecureNoTLS without AllowInsecureAuth
type EmailTemplate ¶
type EmailTemplate struct {
SubjectTemplate string // text/template
HTMLBodyTemplate string // html/template — HTMLBody renders into a real HTML document in the
// recipient's mail client, so it is escaped differently from Subject/TextBody
TextBodyTemplate string // text/template, optional
}
EmailTemplate is the raw template content, usable two ways: registered once by name (EmailTemplateEngine.RegisterTemplate, compiled once, cheap to reuse) or passed inline per-send (EmailMessage.InlineTemplate, compiled fresh each call) — same fields, same rendering rules either way (see EmailTemplateEngine).
SECURITY NOTE, InlineTemplate specifically: the plausible real source for an inline template is per-tenant custom branding — i.e. content that may trace back to a tenant admin's own input, not a trusted grpop operator. html/template auto-escapes DATA values safely, but the template STRUCTURE itself is not sandboxed against referencing EmailMessage's TemplateData fields the caller didn't intend to expose (e.g. an internal field accidentally present in the map) — InlineTemplate content must come from a trusted admin-configured path, never raw end-user input, and EmailTemplateEngine.RenderInline enforces a size cap (returns ErrInlineTemplateTooLarge) so an oversized/pathological template can't cost unbounded parse/render CPU per call — a real cost specifically because RenderInline, unlike Render, is never cached.
type EmailTemplateEngine ¶
type EmailTemplateEngine interface {
// RegisterTemplate compiles tmpl once, under name, for repeated cheap
// reuse via Render — the path for templates known ahead of time.
RegisterTemplate(name string, tmpl EmailTemplate) error
Render(name string, data map[string]any) (subject, htmlBody, textBody string, err error)
// RenderInline compiles and renders tmpl on the fly, with no prior
// RegisterTemplate call — the "pass a custom template at send time"
// path (EmailMessage.InlineTemplate), for content not known ahead of
// RegisterTemplate time (e.g. per-tenant custom branding). Same
// html/template-for-HTMLBody, text/template-for-Subject/TextBody
// rendering rules as Render. Not cached against any name — a caller
// sending the identical inline template repeatedly at high volume
// should register it via RegisterTemplate instead for the
// compile-once benefit; RenderInline compiles fresh every call.
// Returns ErrInlineTemplateTooLarge if tmpl's combined source exceeds
// the configured maximum size.
RenderInline(tmpl EmailTemplate, data map[string]any) (subject, htmlBody, textBody string, err error)
}
EmailTemplateEngine renders EmailMessage.Subject/HTMLBody/TextBody. Uses html/template (NOT text/template) for HTMLBody specifically, because HTMLBody renders into a real HTML document in the recipient's mail client. Subject and TextBody use text/template.
func NewEmailTemplateEngine ¶
func NewEmailTemplateEngine(config EmailTemplateEngineConfig) EmailTemplateEngine
NewEmailTemplateEngine constructs an EmailTemplateEngine.
type EmailTemplateEngineConfig ¶
type EmailTemplateEngineConfig struct {
// MaxInlineTemplateBytes bounds RenderInline's combined
// Subject+HTMLBody+TextBody template source size. Defaults to
// defaultMaxInlineTemplateBytes if <= 0. Never applied to
// RegisterTemplate/Render — a registered template is compiled once and
// reused, so its one-time compile cost isn't the same unbounded-CPU-
// per-call concern RenderInline has.
MaxInlineTemplateBytes int
}
EmailTemplateEngineConfig tunes defaultEmailTemplateEngine.
type IdempotencyStore ¶
type IdempotencyStore interface {
IsProcessed(ctx context.Context, idempotencyKey string) (bool, error)
MarkProcessed(ctx context.Context, idempotencyKey string, ttl time.Duration) error
Close() error
}
IdempotencyStore records which IdempotencyKey values have already been processed, so Service can short-circuit a redelivered send instead of dispatching it twice. Backed by grcache.Cache.
func NewCacheIdempotencyStore ¶
func NewCacheIdempotencyStore(cache grcache.Cache) IdempotencyStore
NewCacheIdempotencyStore constructs an IdempotencyStore backed by cache.
Parameters:
- cache: grcache.Cache — caller-owned; not closed by this store's Close (see Close's doc comment)
type Logger ¶
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
}
Logger is the minimal logging interface grpop accepts for optional diagnostic logging (vendor connectivity failures, dispatch retries, circuit-breaker state transitions, idempotency dedup hits, shutdown). Its four methods match *slog.Logger's own signatures exactly, so *slog.Logger satisfies it structurally — grpop itself does not import grlog or log/slog, so plugging in a logger is entirely opt-in and adds no dependency for consumers who don't want one.
A nil Logger passed to any constructor is replaced with NopLogger() — logging is always optional, never required for grpop to function.
Example, using grlog via its log/slog adapter (the recommended bridge — grlog itself needs no code changes for this):
import ( "log/slog" "github.com/gourdian25/grlog" ) logger := slog.New(grlog.NewSlogHandler(grlog.NewDefaultLogger())) deps.Logger = logger svc, err := grpop.NewService(deps)
func NopLogger ¶
func NopLogger() Logger
NopLogger returns a Logger that discards every message. It is the default used whenever no Logger is configured.
Returns:
- Logger: a non-nil, no-op implementation safe to call from any goroutine
type MemoryEmailSender ¶
type MemoryEmailSender struct {
// contains filtered or unexported fields
}
MemoryEmailSender is an in-memory EmailSender for tests and local dev — it never contacts a real vendor (no SMTP connection is made at all), and records every message passed to Send for later assertion via Sent().
Distinct from NewDryRunEmailSender (dryrun.go): that one exists for a staging/pre-prod deployment and deliberately does NOT retain rendered message content (grpop's payloads are security-sensitive — see dryrun.go's own doc comment), logging only non-sensitive metadata. MemoryEmailSender is test-only scaffolding, never wired into a shared log sink, so it has no such reason to redact — recording full sends is the point.
func NewMemoryEmailSender ¶
func NewMemoryEmailSender() *MemoryEmailSender
NewMemoryEmailSender constructs a MemoryEmailSender.
func (*MemoryEmailSender) Close ¶
func (s *MemoryEmailSender) Close() error
func (*MemoryEmailSender) Send ¶
func (s *MemoryEmailSender) Send(ctx context.Context, msg EmailMessage) (SendResult, error)
func (*MemoryEmailSender) Sent ¶
func (s *MemoryEmailSender) Sent() []EmailMessage
Sent returns every EmailMessage passed to Send so far, in call order.
type MemoryWhatsAppSender ¶
type MemoryWhatsAppSender struct {
// contains filtered or unexported fields
}
MemoryWhatsAppSender is an in-memory WhatsAppSender for tests and local dev — it never contacts Meta's Graph API, and records every message passed to Send for later assertion via Sent(). See MemoryEmailSender's doc comment for why this differs from NewDryRunWhatsAppSender.
func NewMemoryWhatsAppSender ¶
func NewMemoryWhatsAppSender() *MemoryWhatsAppSender
NewMemoryWhatsAppSender constructs a MemoryWhatsAppSender.
func (*MemoryWhatsAppSender) Close ¶
func (s *MemoryWhatsAppSender) Close() error
func (*MemoryWhatsAppSender) Send ¶
func (s *MemoryWhatsAppSender) Send(ctx context.Context, msg WhatsAppMessage) (SendResult, error)
func (*MemoryWhatsAppSender) Sent ¶
func (s *MemoryWhatsAppSender) Sent() []WhatsAppMessage
Sent returns every WhatsAppMessage passed to Send so far, in call order.
type MessageEncryptor ¶
type MessageEncryptor interface {
Encrypt(plaintext []byte) ([]byte, error)
Decrypt(ciphertext []byte) ([]byte, error)
}
MessageEncryptor optionally encrypts a DLQMessage's serialized bytes before they're written to durable storage, and decrypts on read back — grpop provides only this seam, not a concrete implementation or any key management. A consumer wanting encryption at rest supplies their own (e.g. AES-GCM keyed from whatever secrets manager already backs their other signing keys). Absent one, DLQHandler's storage must be operated with the same access-control rigor as a credentials table — see docs.go.
type MessageFailedPayload ¶
type MessageFailedPayload struct {
SendID string
Channel Channel
Reason string
Timestamp time.Time
}
MessageFailedPayload is published on TopicMessageFailed.
type MessageSentPayload ¶
type MessageSentPayload struct {
// SendID is the caller's own SendOptions.IdempotencyKey — the same
// identifier DLQEvent.SendID uses, so a subscriber can correlate this
// event with a DLQ entry if one exists.
SendID string
Channel Channel
ProviderMessageID string
Timestamp time.Time
}
MessageSentPayload is published on TopicMessageSent.
type MetaCloudDispatcherDeps ¶
type MetaCloudDispatcherDeps struct {
// PhoneNumberID is Meta's phone-number-id path segment for the messages
// endpoint. Required unless Client is supplied directly.
PhoneNumberID string
// BusinessAccountID is Meta's WhatsApp Business Account ID — a distinct
// ID from PhoneNumberID, used only for GetApprovedTemplates (message
// templates belong to the business account, not the phone number). Not
// part of the original plan's Deps field list — added during
// implementation because GetApprovedTemplates has no other way to know
// which account to list templates for (see docs/plan/grpop-plan.md's
// Stage 11 revision note). Required only if a TemplateValidator backed
// by this client is actually used (Stage 12); Send never needs it.
BusinessAccountID string
// AccessToken is a long-lived/System User access token. grpop does NOT
// refresh or rotate this token — token lifecycle is entirely the
// operator's responsibility. Required unless Client is supplied
// directly.
AccessToken string
// RequestTimeout bounds one Graph API HTTP call, applying in addition
// to ctx's own deadline, whichever is sooner. Default: 10s.
RequestTimeout time.Duration
// Client is optional; nil constructs a real net/http-backed one from
// PhoneNumberID/BusinessAccountID/AccessToken/RequestTimeout.
Client WhatsAppCloudAPIClient
TemplateValidator TemplateValidator
RateLimiter RateLimiter
CircuitBreaker CircuitBreaker
Metrics Metrics
Logger Logger
}
MetaCloudDispatcherDeps configures NewMetaCloudWhatsAppDispatcher.
type MetaTemplateValidatorDeps ¶
type MetaTemplateValidatorDeps struct {
// Client is required — the same WhatsAppCloudAPIClient a
// metaCloudDispatcher uses; GetApprovedTemplates is the only method this
// validator calls.
Client WhatsAppCloudAPIClient
// TTL bounds how long a fetched approved-template list is trusted
// before the next Validate call triggers a re-fetch. Defaults to
// defaultTemplateValidatorTTL if <= 0.
TTL time.Duration
Logger Logger
}
MetaTemplateValidatorDeps configures NewMetaTemplateValidator.
type Metrics ¶
type Metrics interface {
ObserveSendLatency(channel Channel, duration time.Duration)
IncSendResult(channel Channel, status SendStatus)
IncRateLimitRejected(channel Channel)
IncDLQPublished(channel Channel)
IncCircuitBreakerStateChange(channel Channel, newState string)
// IncIdempotencyDedupHit records every SendResult.Duplicate == true
// outcome, alongside Service's own Warn-level log line, for a
// caller-bug class (an IdempotencyKey reused across two genuinely
// different message bodies) that would otherwise manifest only as
// "the second message silently never went out," with nothing in any
// log or metric to explain why.
IncIdempotencyDedupHit(channel Channel)
// ObserveSMTPResponseCode records the raw SMTP reply code (2xx/4xx/5xx)
// from every send attempt, even though grpop itself takes no action on
// the code beyond retry/DLQ classification. A rising rate of 5xx (or a
// creeping 4xx rate) on an otherwise-succeeding send path is an early
// signal of sender-reputation damage on the configured relay — visible
// here well before it would show up as a drop in actual delivered
// mail, which grpop has no way to observe at all (no vendor-side
// bounce/complaint feedback).
ObserveSMTPResponseCode(code int)
}
Metrics is the optional observability surface every dispatcher/Service component accepts. All methods are fire-and-forget from the caller's perspective — a nil Metrics is a silent no-op (OrNop-equivalent), and a real implementation should never block or error out the operation it's instrumenting.
type MongoDLQHandlerConfig ¶
type MongoDLQHandlerConfig struct {
URI string
Database string
CollectionName string // defaults to DefaultDLQCollection
MaxRetries int // defaults to 3
RetryDelay time.Duration // passed through as-is; 0 means immediately retry-eligible
MaxRetryDelay time.Duration // passed through as-is to FullJitterBackoff
// MaxAttemptHistoryEntries caps DLQEvent.AttemptHistory, oldest entry
// dropped first once exceeded. Defaults to 20 if <= 0. Enforced via
// $push's $slice modifier at write time (see PublishToDLQ/MarkRetried),
// MongoDB's native equivalent of dlq.postgres.go's SQL-side cap.
MaxAttemptHistoryEntries int
// Encryptor, if set, encrypts DLQMessage's serialized bytes before
// they're written to message_data, and decrypts on read back. Nil (the
// default) stores message_data in the clear. Unlike Postgres's JSONB
// column, Mongo's message_data field is stored as native BSON binary
// ([]byte), so — unlike dlq.postgres.go — no base64 wrapping is needed
// to make arbitrary ciphertext bytes storable.
Encryptor MessageEncryptor
Logger Logger
}
MongoDLQHandlerConfig configures a DLQHandler constructed by NewMongoDLQHandler.
type PostgresConfig ¶
type PostgresConfig struct {
// DSN is a standard libpq/pgx connection string. Exactly one of DSN
// or Pool must be set.
DSN string
// Pool, if set, is used directly instead of dialing a new pool from
// DSN — lets multiple grpop/grnoti/other-sibling Postgres stores share
// one pgxpool.Pool instead of each opening its own. grpop never closes
// a Pool it did not create itself: Close only closes the pool when it
// was dialed from DSN. Exactly one of DSN or Pool must be set.
Pool *pgxpool.Pool
// MaxConns caps the pgxpool connection pool size. 0 means use pgxpool's
// own default. Ignored when Pool is set — tune the pool yourself
// before passing it in.
MaxConns int32
// MinConns keeps at least this many connections open. 0 means use
// pgxpool's own default. Ignored when Pool is set.
MinConns int32
// MaxConnLifetime bounds how long a pooled connection may be reused
// before being recycled. 0 means pgxpool's own default (unlimited).
// Ignored when Pool is set.
MaxConnLifetime time.Duration
// ConnectTimeout bounds dialing and the initial Ping when connecting
// from DSN. 0 means 10 seconds. Ignored when Pool is set (the Ping
// against an already-established Pool uses the 10-second default
// unconditionally).
ConnectTimeout time.Duration
// SkipSchemaEnsure, if true, skips applying grpop's embedded schema on
// this connect call — for teams that manage the schema through their
// own migration pipeline instead of grpop's built-in CREATE TABLE IF
// NOT EXISTS.
SkipSchemaEnsure bool
// Logger receives optional diagnostic messages. A nil Logger disables
// logging.
Logger Logger
}
PostgresConfig is the connection configuration for the Postgres-backed DLQHandler.
type PostgresDLQHandlerConfig ¶
type PostgresDLQHandlerConfig struct {
PostgresConfig
MaxRetries int // defaults to 3
RetryDelay time.Duration // 0 is a valid, deliberate "immediately retry-eligible" choice — not defaulted
MaxRetryDelay time.Duration // passed through to FullJitterBackoff as-is
// MaxAttemptHistoryEntries caps DLQEvent.AttemptHistory, oldest entry
// dropped first once exceeded. Defaults to 20 if <= 0. Enforced in SQL
// (see internal/postgresdb/queries/dlq.sql's UpsertDLQEvent/
// FinalizeRetryPending/FinalizeRetryTerminal), not in Go — unlike
// memoryDLQHandler's appendAttemptCapped, which runs after a round trip
// through Go anyway.
MaxAttemptHistoryEntries int
// Encryptor, if set, encrypts DLQMessage's serialized bytes before
// they're written to message_data, and decrypts on read back. Nil (the
// default) stores message_data in the clear — see docs.go's warning
// about operating grpop_dlq with credentials-table-grade access
// control in that case.
Encryptor MessageEncryptor
}
PostgresDLQHandlerConfig configures a DLQHandler constructed by NewPostgresDLQHandler — the primary DLQ backend.
type RateLimiter ¶
type RateLimiter interface {
// Allow reports whether a request may proceed right now, without
// blocking. Consumes a token if true.
Allow(ctx context.Context, channel Channel, recipient string) (bool, error)
// Wait blocks until a token is available or ctx is done.
Wait(ctx context.Context, channel Channel, recipient string) error
// GetStats returns a point-in-time snapshot of this (channel,
// recipient) bucket's counters.
GetStats(ctx context.Context, channel Channel, recipient string) (RateLimiterStats, error)
}
RateLimiter gates sends per (channel, recipient) AND per channel alone — a deliberate two-tier design, not a single global bucket: a per- recipient limit alone can't stop a single actor from triggering many distinct recipients' sends in a burst (e.g. a scripted forgot-password sweep across a whole user list), and a per-channel limit alone can't stop one recipient from being spammed by many distinct callers.
func NewLocalRateLimiter ¶
func NewLocalRateLimiter(requestsPerSecond, burstSize, recipientCacheSize int) (RateLimiter, error)
NewLocalRateLimiter constructs a per-process, two-tier RateLimiter.
Parameters:
- requestsPerSecond: int — must be > 0; applies to both the per-channel and per-(channel,recipient) tiers
- burstSize: int — must be >= requestsPerSecond
- recipientCacheSize: int — bounds the number of distinct recipients tracked at once; defaults to defaultRecipientCacheSize if <= 0. When the bound is reached, the least-recently-used recipient's bucket is evicted and starts fresh on its next request — a bounded loss of rate-limit precision under high recipient cardinality, not a correctness or security issue (the per-channel tier is unaffected and keeps limiting the aggregate rate regardless).
Returns:
- RateLimiter
- error: non-nil if either rate constraint is violated
func NewRedisRateLimiter ¶
func NewRedisRateLimiter(cfg RedisRateLimiterConfig) (RateLimiter, error)
NewRedisRateLimiter builds its own *redis.Client from cfg and validates connectivity with a Ping before returning, mirroring grcache/redis's constructor-time validation.
Parameters:
- cfg: RedisRateLimiterConfig — Addr, RequestsPerSecond, and BurstSize are required; other fields default (see field docs)
Returns:
- RateLimiter: ready to use, shared across every process using the same cfg.Addr/cfg.KeyPrefix pair
- error: non-nil if a required field is invalid or the connection fails
type RateLimiterStats ¶
type RateLimiterStats struct {
RequestsPerSecond int
BurstSize int
AllowedCount int64
BlockedCount int64
WaitCount int64
LastAllowedAt time.Time
}
RateLimiterStats is a point-in-time snapshot of one (channel, recipient) bucket's counters.
type RedisRateLimiterConfig ¶
type RedisRateLimiterConfig struct {
// Addr is the Redis server address, e.g. "localhost:6379". Required.
Addr string
// Password authenticates with the server. Empty means no auth.
Password string
// DB selects the Redis logical database.
DB int
// PoolSize is the maximum number of connections in the pool. Defaults to 100.
PoolSize int
// DialTimeout bounds how long connecting to Redis may take. Defaults to 5s.
DialTimeout time.Duration
// ReadTimeout bounds how long a read may take. Defaults to 3s.
ReadTimeout time.Duration
// WriteTimeout bounds how long a write may take. Defaults to 3s.
WriteTimeout time.Duration
// RequestsPerSecond is each bucket's steady-state refill rate, shared
// across every process using the same KeyPrefix. Required, must be > 0.
// Applies to both the per-channel and per-(channel,recipient) tiers —
// same single-knob tradeoff as NewLocalRateLimiter.
RequestsPerSecond int
// BurstSize is each bucket's capacity. Required, must be >= RequestsPerSecond.
BurstSize int
// KeyPrefix namespaces every Redis key this limiter touches. All
// processes that should share one distributed quota must use the same
// KeyPrefix. Defaults to "grpop:ratelimit". Unlike
// NewLocalRateLimiter's in-process recipient cache, there is no
// eviction/cardinality bound to configure here — Redis itself is the
// shared store, and each bucket key expires on its own via
// redisRateLimiterKeyTTL when idle.
KeyPrefix string
// Logger receives optional diagnostic messages. A nil Logger disables logging.
Logger Logger
}
RedisRateLimiterConfig configures a redisRateLimiter constructed by NewRedisRateLimiter. Zero-valued connection fields fall back to the same defaults as grcache/redis's RedisConfig; RequestsPerSecond and BurstSize have no sensible zero value and must be set explicitly.
type SMTPClient ¶
type SMTPClient interface {
Auth(a smtp.Auth) error
Mail(from string) error
Rcpt(to string) error
Data() (io.WriteCloser, error)
Close() error
}
SMTPClient is the subset of *net/smtp.Client's method set that Send actually drives — narrow enough to fake in unit tests, and satisfied by *smtp.Client itself with no adapter needed. Unlike a vendor-SDK-shaped narrow interface, this one's real backend can also be exercised against a real local Mailpit/MailHog SMTP server in integration tests — it is a fakeability seam for unit tests, not a documented "no real backend available" exception.
type SMTPDialer ¶
type SMTPDialer interface {
Dial(addr string) (SMTPClient, error)
}
SMTPDialer opens a connection to addr and returns it ready for an Auth/Mail/Rcpt/Data transaction — any TLS handshake (implicit or STARTTLS) and the initial EHLO happen inside Dial, since SMTPClient's own method set has no room for them. A real implementation is a thin wrapper over net/smtp.Dial/NewClient/StartTLS; nil in SMTPDispatcherDeps uses one backed by the real network.
type SMTPDispatcherDeps ¶
type SMTPDispatcherDeps struct {
// Addr is the SMTP relay's host:port, e.g.
// "email-smtp.us-east-1.amazonaws.com:587". SES/SendGrid/Postmark/
// Mailgun/a bare relay are all just different values here, never a
// different Go dependency. Required.
Addr string
Auth smtp.Auth
// DefaultFrom is used when an individual EmailMessage.From is empty. At
// least one of the two must be set at Send time, or Send returns
// ErrEmailFromRequired.
DefaultFrom string
// TLSMode defaults to SMTPTLSStartTLS if unset (the zero value maps to
// the secure default, not to SMTPTLSInsecureNoTLS). Constructing with
// Auth set and TLSMode == SMTPTLSInsecureNoTLS is a construction-time
// error (credentials over plaintext) unless AllowInsecureAuth is also
// explicitly set.
TLSMode SMTPTLSMode
AllowInsecureAuth bool
// ConnectTimeout/SendTimeout bound one Send call end-to-end so a hanging
// vendor connection can't stall the caller's own request indefinitely —
// every Send is synchronous on the caller's own goroutine. Both apply as
// a ceiling in addition to, never instead of, ctx's own deadline if the
// caller supplied one. Defaults: ConnectTimeout 5s, SendTimeout 15s.
ConnectTimeout time.Duration
SendTimeout time.Duration
// Dialer is optional; nil uses a real net/smtp-backed dialer.
Dialer SMTPDialer
// TemplateEngine renders EmailMessage.TemplateName/InlineTemplate into a
// literal Subject/HTMLBody/TextBody before the MIME message is built.
// Not part of the original plan's Deps field list — added during
// implementation because content-mode resolution has to happen
// somewhere before the vendor call, and no earlier stage owns it yet
// (see docs/plan/grpop-plan.md's Stage 10 revision note). Required only
// if a Send call actually uses TemplateName/InlineTemplate; a literal
// EmailMessage never touches it. A nil TemplateEngine with a
// template-mode message returns ErrEmailTemplateEngineRequired.
TemplateEngine EmailTemplateEngine
RateLimiter RateLimiter
CircuitBreaker CircuitBreaker
Metrics Metrics
Logger Logger
}
SMTPDispatcherDeps configures NewSMTPDispatcher.
type SMTPTLSMode ¶
type SMTPTLSMode string
SMTPTLSMode controls how dispatcher.email.smtp.go secures its connection to SMTPDispatcherDeps.Addr. Mandatory-by-default, not implicit: unlike a vendor SDK (SES/SendGrid) that handles transport security internally, grpop owns the raw connection here, so it must get this right itself.
const ( // SMTPTLSStartTLS is the default: a plaintext connection is upgraded via // STARTTLS before AUTH/MAIL is attempted. SMTPTLSStartTLS SMTPTLSMode = "starttls" // SMTPTLSImplicit dials with TLS from the first byte (e.g. port 465). SMTPTLSImplicit SMTPTLSMode = "implicit" // SMTPTLSInsecureNoTLS is plaintext, no TLS at any point — deliberately // loud name to discourage accidental production use. Intended only for // a local Mailpit/MailHog test container. SMTPTLSInsecureNoTLS SMTPTLSMode = "insecure_no_tls" )
type SendOptions ¶
type SendOptions struct {
// IdempotencyKey is REQUIRED — Service.SendEmail/SendWhatsApp return
// ErrIdempotencyKeyRequired if it's empty. grpop's inline retry-on-
// transient-failure is at-least-once, not exactly-once (docs.go): a
// network error after the vendor already accepted the message is
// indistinguishable from one before, so a retry can double-send. The
// guarantee against a caller-visible double-send comes entirely from
// the caller supplying a stable key and IdempotencyStore catching the
// redelivery.
IdempotencyKey string
IdempotencyTTL time.Duration // 0 uses the configured default
SkipRateLimit bool // escape hatch, e.g. an admin-triggered manual resend
// RetryExpiresAt is the hard wall-clock deadline after which grpop
// stops retrying a failed send entirely, regardless of remaining
// retry-count budget. Zero value uses the configured default max retry
// age, measured from the first failure, not from this call. Callers
// who know their own message's real expiry (a password-reset link's
// TTL, an invite token's expiry) should set this explicitly rather
// than relying on the library default.
RetryExpiresAt time.Time
}
SendOptions carries per-send cross-cutting behavior.
type SendResult ¶
type SendResult struct {
Channel Channel
ProviderMessageID string // vendor's own message/tracking ID (Meta's wamid, or the
// generated SMTP Message-ID); empty if Status is SendStatusFailed
Status SendStatus
SentAt time.Time
Raw map[string]string // optional vendor-specific diagnostic fields — logging only
// Duplicate is true if this call short-circuited on
// IdempotencyStore.IsProcessed (the same IdempotencyKey was already
// marked processed) rather than performing a new vendor call. A dedup
// hit is not silent: Service also logs a Warn and increments
// Metrics.IncIdempotencyDedupHit on every hit regardless of whether the
// caller inspects this field — see Service's doc comment.
Duplicate bool
}
SendResult is the delivery-status handle every Service.SendX call returns.
type SendStatus ¶
type SendStatus string
SendStatus is the result of a vendor call, not a delivery-receipt status. See docs.go's "precise, non-aspirational claims" section.
const ( SendStatusSent SendStatus = "sent" // the vendor accepted the request SendStatusFailed SendStatus = "failed" // the vendor rejected the request, or the call itself errored )
type Service ¶
type Service interface {
SendEmail(ctx context.Context, msg EmailMessage, opts SendOptions) (SendResult, error)
SendWhatsApp(ctx context.Context, msg WhatsAppMessage, opts SendOptions) (SendResult, error)
Close() error
}
Service is the top-level orchestrator: checks idempotency, gates through the rate limiter, dispatches via the channel-appropriate Sender directly (no queue/broker in between — see docs.go), publishes to DLQHandler on exhausted inline-retry failure, and best-effort-publishes a lifecycle event via grevents. Every SendX method is synchronous on the calling goroutine.
SendEmail/SendWhatsApp return ErrIdempotencyKeyRequired immediately, before any rate-limit check or vendor call, if opts.IdempotencyKey is empty.
When IdempotencyStore.IsProcessed reports an incoming IdempotencyKey as already processed, Service returns immediately with SendResult{Duplicate: true, ...} — no vendor call, no rate-limit consumption — but first logs a Warn (key + channel only, never message content) and increments Metrics.IncIdempotencyDedupHit. A dedup hit is the expected, correct outcome for a genuine retried request, but is otherwise indistinguishable from a real caller bug (the same key accidentally reused for two different message bodies) without this visibility.
func NewService ¶
func NewService(deps ServiceDeps) (Service, error)
NewService constructs a Service.
Parameters:
- deps: ServiceDeps — IdempotencyStore, DLQHandler, EmailSender, WhatsAppSender are required
Returns:
- Service
- error: non-nil if a required dependency is missing
type ServiceConfig ¶
type ServiceConfig struct {
// DefaultIdempotencyTTL is used when SendOptions.IdempotencyTTL is
// zero. Defaults to defaultServiceIdempotencyTTL if <= 0.
DefaultIdempotencyTTL time.Duration
// DefaultMaxRetryAge is used when SendOptions.RetryExpiresAt is zero,
// measured from the moment a send is published to the DLQ. Defaults to
// defaultServiceMaxRetryAge if <= 0.
DefaultMaxRetryAge time.Duration
// MaxInlineRetries bounds Service's own inline Full-Jitter retry loop
// for a transient per-call send failure, run before falling through to
// DLQHandler.PublishToDLQ (docs.go: no message broker anywhere in
// grpop — every send is direct and synchronous, so this retry runs on
// the caller's own goroutine). Total attempts = MaxInlineRetries + 1.
// Defaults to defaultServiceMaxInlineRetries if < 0 (0 is a valid,
// deliberate choice: no inline retry at all, straight to DLQ on the
// first failure).
MaxInlineRetries int
// InlineRetryBaseDelay/InlineRetryMaxDelay feed FullJitterBackoff
// between inline retry attempts. Default to
// defaultServiceInlineRetryBaseDelay/MaxDelay if <= 0.
InlineRetryBaseDelay time.Duration
InlineRetryMaxDelay time.Duration
}
ServiceConfig tunes service's idempotency/retry behavior.
func DefaultServiceConfig ¶
func DefaultServiceConfig() ServiceConfig
DefaultServiceConfig returns a sane starting configuration.
type ServiceDeps ¶
type ServiceDeps struct {
// IdempotencyStore, DLQHandler, EmailSender, WhatsAppSender are
// required.
IdempotencyStore IdempotencyStore
DLQHandler DLQHandler
EmailSender EmailSender
WhatsAppSender WhatsAppSender
// EventBus, if set, receives TopicMessageSent/TopicMessageFailed
// lifecycle events (events.go). Optional and nil-safe — best-effort
// only, per docs.go.
EventBus grevents.Bus
// Metrics is optional. Service only calls the two counters that are
// exclusively its own responsibility: IncIdempotencyDedupHit (only
// Service sees idempotency dedup hits) and IncDLQPublished (only
// Service calls DLQHandler.PublishToDLQ). It deliberately does NOT call
// ObserveSendLatency/IncSendResult itself, since
// dispatcher.email.smtp.go/dispatcher.whatsapp.metacloud.go already
// call those on every Send attempt when the same Metrics instance is
// wired into their own Deps — calling them again here would
// double-count, exactly the reasoning grnoti's own ServiceDeps.Metrics
// doc comment gives for not calling IncInvalidTokens twice.
Metrics Metrics
Config ServiceConfig
Logger Logger
}
ServiceDeps configures a Service constructed by NewService.
RateLimiter is deliberately NOT a field here, even though an earlier draft of docs/plan/grpop-plan.md's §10 wiring example passed the same RateLimiter to both a dispatcher's Deps and ServiceDeps. That double-wires the same limiter: RateLimiter.Allow/Wait consumes a token per call, and Service's own inline retry (below) calls EmailSender.Send/ WhatsAppSender.Send more than once per logical send — since dispatcher.email.smtp.go/dispatcher.whatsapp.metacloud.go already gate every individual Send attempt through their own configured RateLimiter (Stage 10/11), a second gate here would consume two-to-four tokens for one logical send with no way for either layer to know about the other's consumption. Rate limiting belongs at the dispatcher layer only, where it naturally covers every inline-retry attempt, not just the first. Configure RateLimiter on SMTPDispatcherDeps/MetaCloudDispatcherDeps instead.
EmailTemplateEngine is similarly absent: template rendering was moved into dispatcher.email.smtp.go itself (SMTPDispatcherDeps.TemplateEngine) during Stage 10, since content-mode resolution has to happen immediately before the MIME message is built — Service never sees a template, only the EmailSender that already knows how to render one.
type TemplateValidator ¶
type TemplateValidator interface {
// Validate returns nil if templateName+languageCode is approved and
// len(variables) matches its expected parameter count;
// ErrWhatsAppTemplateNotApproved if not approved;
// ErrWhatsAppTemplateArityMismatch if approved but the variable count
// doesn't match.
Validate(ctx context.Context, templateName, languageCode string, variables map[string]string) error
// Refresh forces an immediate re-fetch of the approved-template list,
// bypassing the internal cache — e.g. call this right after
// registering a new template with Meta, instead of waiting out the
// TTL.
Refresh(ctx context.Context) error
}
TemplateValidator confirms a WhatsApp template name+language is currently approved on Meta's side AND that the supplied variables' count matches the approved template's expected parameter count — catching the two most common vendor-rejection causes before a live send is attempted. Backed by Meta's own template-listing endpoint, with a short internal TTL cache (templates change rarely, so this is not re-fetched on every call).
TemplateValidator is advisory, not mandatory: a WhatsApp dispatcher consults it only if one is configured, and its cache means "approved as of the last check," not a live guarantee — Meta can approve, reject, or delete a template between grpop's last cache refresh and a live send. The underlying vendor call remains the ultimate source of truth: a passed Validate check never causes a dispatcher to skip actually calling Meta.
func NewMetaTemplateValidator ¶
func NewMetaTemplateValidator(deps MetaTemplateValidatorDeps) (TemplateValidator, error)
NewMetaTemplateValidator constructs a TemplateValidator backed by Meta's own approved-template list.
Parameters:
- deps: MetaTemplateValidatorDeps — deps.Client is required
Returns:
- TemplateValidator
- error: non-nil if deps.Client is nil
type WhatsAppCloudAPIClient ¶
type WhatsAppCloudAPIClient interface {
SendTemplateMessage(ctx context.Context, req WhatsAppCloudAPIRequest) (WhatsAppCloudAPIResponse, error)
// GetApprovedTemplates backs TemplateValidator (templatevalidator.whatsapp.go).
GetApprovedTemplates(ctx context.Context) ([]WhatsAppTemplateInfo, error)
}
WhatsAppCloudAPIClient is grpop's own thin interface over Meta's Graph API messages endpoint — no official Go SDK exists for this, so this is a hand-rolled HTTP client, kept narrow for fakeability. This is the one deliberate exception to grpop's real-services testing policy: Meta's Graph API has no local emulator, so metaCloudDispatcher's own retry/ error-classification/rate-limiter/circuit-breaker wiring is tested against a fake implementation of this interface instead.
type WhatsAppCloudAPIRequest ¶
type WhatsAppCloudAPIRequest struct {
To string
TemplateName string
LanguageCode string
TemplateVariables map[string]string
}
WhatsAppCloudAPIRequest is grpop's own vendor-neutral shape for one WhatsApp template send, translated from a WhatsAppMessage by metaCloudDispatcher before reaching WhatsAppCloudAPIClient.
type WhatsAppCloudAPIResponse ¶
type WhatsAppCloudAPIResponse struct {
// MessageID is Meta's own wamid for the sent message.
MessageID string
}
WhatsAppCloudAPIResponse is the vendor-neutral result of one successful WhatsAppCloudAPIClient.SendTemplateMessage call.
type WhatsAppMessage ¶
type WhatsAppMessage struct {
To string // E.164 phone number, required
TemplateName string // required — the Meta-approved template's name; any approved name
// works, nothing hardcoded/enumerated grpop-side
TemplateVariables map[string]string // stringified positional keys ("1","2","3", in order),
// per Meta Cloud API's template-parameter convention
LanguageCode string // e.g. "en_US", required, must match the approved template's
}
WhatsAppMessage is structurally different from EmailMessage on purpose: WhatsApp requires a pre-approved message template for anything sent outside a user-initiated 24-hour session window, which every one of grpop's intended use cases falls into (system-initiated sends, never a reply to a live user session). There is deliberately no freeform Body field.
"Custom template" for WhatsApp means something different from email's InlineTemplate: TemplateName is a free string, not restricted to a fixed/hardcoded set, so any template a caller has had approved by Meta (however new, however tenant/feature-specific) can be sent by name with no grpop-side registration step at all. What's NOT possible, as a hard vendor constraint rather than a grpop design gap: there is no ad-hoc/ unregistered-with-Meta WhatsApp template — grpop cannot render or invent WhatsApp template content itself the way EmailTemplateEngine.RenderInline can for email, because Meta's own approval process is the source of truth for what content is allowed to go out. TemplateValidator only checks against whatever is already approved; it never submits new ones.
type WhatsAppSender ¶
type WhatsAppSender interface {
Send(ctx context.Context, msg WhatsAppMessage) (SendResult, error)
Close() error
}
WhatsAppSender dispatches a single WhatsAppMessage. See EmailSender's doc comment for why this is a distinct interface, not a unified one.
func NewDryRunWhatsAppSender ¶
func NewDryRunWhatsAppSender(logger Logger) WhatsAppSender
NewDryRunWhatsAppSender returns a WhatsAppSender that never contacts Meta's Graph API. See NewDryRunEmailSender's doc comment for the full rationale (redaction, construction-time-swap convention) — identical here, substituting TemplateName (WhatsApp has no rendered body at all, see WhatsAppMessage's own doc comment) for email's content-mode description, and masking the recipient phone number the same way dispatcher.whatsapp.metacloud.go's own logging does.
func NewMetaCloudWhatsAppDispatcher ¶
func NewMetaCloudWhatsAppDispatcher(deps MetaCloudDispatcherDeps) (WhatsAppSender, error)
NewMetaCloudWhatsAppDispatcher constructs a WhatsAppSender backed by Meta's WhatsApp Business Cloud API.
Parameters:
- deps: MetaCloudDispatcherDeps — deps.PhoneNumberID and deps.AccessToken are required unless deps.Client is supplied directly
Returns:
- WhatsAppSender
- error: non-nil if deps.Client is nil and PhoneNumberID/AccessToken are not both set
type WhatsAppTemplateInfo ¶
type WhatsAppTemplateInfo struct {
Name string
LanguageCode string
ParameterCount int // number of positional variables ("1","2","3", ...) the approved
}
WhatsAppTemplateInfo describes one Meta-approved template, as returned by a WhatsApp dispatcher's vendor-narrow client.
Source Files
¶
- cache.idempotency.go
- circuitbreaker.go
- dispatcher.email.smtp.go
- dispatcher.whatsapp.metacloud.go
- dlq.mongo.go
- dlq.postgres.go
- docs.go
- dryrun.go
- errors.go
- events.go
- interfaces.go
- logger.go
- lru.go
- memory.go
- payloadvalidator.go
- postgres.go
- ratelimiter.go
- ratelimiter.redis.go
- retrystrategy.go
- service.go
- templateengine.email.go
- templatevalidator.whatsapp.go
- types.go
- version.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Command example sends grpop's four grounding ERP use cases (grpop-plan.md §2: auth.ForgotPassword, provider.IssueInvite, admin.IssueInvite, a tenant's first-admin invite) through a real grpop.Service — idempotency, inline retry, DLQ-on-failure, all wired up — so the whole pipeline can be exercised end to end before being wired into a real application.
|
Command example sends grpop's four grounding ERP use cases (grpop-plan.md §2: auth.ForgotPassword, provider.IssueInvite, admin.IssueInvite, a tenant's first-admin invite) through a real grpop.Service — idempotency, inline retry, DLQ-on-failure, all wired up — so the whole pipeline can be exercised end to end before being wired into a real application. |
|
internal
|
|