Files
zeekay 95ded4f190 notify: Hanzo default brand, ZAP-native KMS, Twilio-native email
Three decomplected fixes on one tree:

1. Brand default — replace the hanzo fleet-default with Hanzo. The
   no-mixing rule forbids a hanzo brand in this Hanzo OSS repo;
   DefaultBrand is now "hanzo" and every KMS path is brand/hanzo/*.
   (History is rewritten separately so the leak leaves no trace.)

2. KMS over the mesh — kmsbridge.NormalizeEndpoint is the single seam
   that maps the fleet's mesh-style zap://kms.hanzo.svc:9999 onto KMS's
   HTTP secrets API (KMS has no ZAP listener: scheme->http, :9999 mesh
   port dropped). zap://, http://, https://, and bare host all resolve
   to the same HTTP base URL; main.go feeds the normalized endpoint to
   the base platform plugin too, so notify dials ONE KMS endpoint.

3. Email is Twilio-native — drop the SES/amazonses provider entirely.
   Every email class leads with twilio_email (comms.twilio.com/v1/Emails,
   Basic auth on the SAME Twilio account creds as SMS); sendgrid stays as
   the marketing-class fallback. RawSender (RFC 8058 List-Unsubscribe)
   now rides twilio_email via its headers map. Also folds in the build
   repair (base/age go.sum, the missing RawSender decl) and the Twilio
   SMS env-cred fallback so KMS-less sends resolve.

Build + vet clean; all 30 packages green, incl. new TestNormalizeEndpoint,
TestNew_NormalizesZapEndpoint, and twilio_email header tests.
2026-06-24 04:34:28 -07:00

790 lines
27 KiB
Go

// Copyright 2026 The Hanzo Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
package tenant
// Multi-provider retry / fallback for notify.
//
// Today notify resolves ONE provider per send. That makes Plivo a single
// point of failure for SMS and Twilio the single point of failure for
// email. This file generalizes the resolver into a per-channel
// ordered chain: primary → fallback1 → fallback2. On a retryable
// failure we walk to the next provider; on a terminal failure (invalid
// recipient, blocklist) we short-circuit.
//
// One way to wire it:
//
// 1. ChainResolver.Resolve(ctx, brand, channel) returns a
// ProviderChain pre-bound with the brand's KMS-resolved provider
// instances in order.
// 2. ProviderChain.Run(ctx, to, subject, body) walks the chain,
// retries across providers on transient failures, returns the
// attempt log + the winning provider id (or the terminal error).
// 3. Activities.Deliver consumes (1)+(2) — no other call site.
//
// Per-tenant overrides live in KMS at brand/<slug>/notify-chain/<channel>
// as a JSON array of provider ids; absent → default chain per
// DefaultChainFor.
//
// KMS layout for provider credentials (canonical):
//
// brand/<slug>/plivo/{auth-id, auth-token, sender-id, sender-id-link, from-email}
// brand/<slug>/twilio/{account-sid, auth-token, from-phone, messaging-sid, from-email, sender-domain}
// brand/<slug>/sendgrid/{api-key, from-address, from-name}
//
// Twilio is a dual-channel provider: SMS uses {account-sid, auth-token,
// from-phone|messaging-sid} (provider id "twilio"); the native Email API
// uses the SAME {account-sid, auth-token} plus {from-email, sender-domain}
// (provider id "twilio_email"). One Twilio account, one credential pair,
// two channels — so Twilio is both the SMS and the email backbone, and
// email needs no separate cloud-mail provider.
//
// Same brand-fallback rule as PlivoResolver: missing brand secrets
// fall through to brand/hanzo/<provider>/* (the fleet default).
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/hanzoai/notify"
"github.com/hanzoai/notify/internal/kmsbridge"
"github.com/hanzoai/notify/service/plivo"
"github.com/hanzoai/notify/service/sendgrid"
"github.com/hanzoai/notify/service/twilio"
"github.com/hanzoai/notify/service/twilioemail"
)
// ChainChannel selects which provider chain to resolve. It is finer-
// grained than types.Channel because email has three distinct routing
// profiles (transactional, OTP, marketing) that can want different
// providers — the transactional/OTP classes lead with Twilio's native
// Email API; marketing keeps sendgrid as a fallback for its RFC 8058
// header needs. SMS has a single chain today.
type ChainChannel string
const (
ChainSMS ChainChannel = "sms"
ChainEmailTxn ChainChannel = "email_txn"
ChainEmailOTP ChainChannel = "email_otp"
ChainEmailMarketing ChainChannel = "email_marketing"
)
// canonicalChannel narrows arbitrary inputs to a known ChainChannel.
// Empty / unknown values default to ChainSMS-vs-ChainEmailTxn based on
// the wire-level Channel (caller passes types.Channel("sms"|"email")).
//
// Callers can pass either the wire channel ("sms", "email") or the fine
// ChainChannel string directly ("email_otp"). Both resolve.
func canonicalChannel(s string) ChainChannel {
switch ChainChannel(strings.ToLower(strings.TrimSpace(s))) {
case ChainSMS:
return ChainSMS
case ChainEmailTxn:
return ChainEmailTxn
case ChainEmailOTP:
return ChainEmailOTP
case ChainEmailMarketing:
return ChainEmailMarketing
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "sms":
return ChainSMS
case "email":
return ChainEmailTxn
}
return ""
}
// DefaultChainFor returns the default ordered provider id list for a
// channel. Per-tenant overrides supersede this; see ChainResolver.
//
// The default chain is the hanzo fleet default (DefaultBrand). Email is
// Twilio-native — every email class leads with Twilio's Email API, which
// shares the SMS account credentials:
//
// SMS → plivo, twilio
// email_txn → twilio_email
// email_otp → twilio_email
// email_marketing → twilio_email, sendgrid
//
// Marketing keeps sendgrid as a fallback because that class needs RFC
// 8058 List-Unsubscribe headers; transactional/OTP need only the native
// Twilio Email API.
//
// All providers are optional at the per-brand level: a missing secret
// for a fallback provider is treated as "this provider not configured
// for this brand" and the chain skips it without erroring (provided at
// least one provider in the chain succeeded or surfaced a terminal
// error).
func DefaultChainFor(ch ChainChannel) []string {
switch ch {
case ChainSMS:
return []string{ProviderPlivo, ProviderTwilio}
case ChainEmailTxn:
return []string{ProviderTwilioEmail}
case ChainEmailOTP:
return []string{ProviderTwilioEmail}
case ChainEmailMarketing:
return []string{ProviderTwilioEmail, ProviderSendGrid}
}
return nil
}
// Canonical provider ids. These are the strings used in KMS-stored
// chain JSON, in telemetry logs, and on the Message row's `provider`
// field. They MUST stay stable — they are part of the wire contract
// with the platform UI's "current effective chain" indicator.
const (
ProviderPlivo = "plivo"
ProviderTwilio = "twilio" // Twilio SMS
ProviderTwilioEmail = "twilio_email" // Twilio native Email API
ProviderSendGrid = "sendgrid"
)
// providerChainKMSPath is the KMS path where per-tenant chain overrides
// live: brand/<slug>/notify-chain/<channel> → JSON array of provider ids.
const providerChainKMSPath = "notify-chain"
// perProviderAttemptTimeout caps any single provider's Send call. A
// hung HTTP socket cannot poison the entire chain — we move on to the
// next provider after this much time.
const perProviderAttemptTimeout = 10 * time.Second
// perSendTotalDeadline caps the whole chain. The route handler may
// already have a longer context (e.g. async via tasks), but we install
// our own ceiling so a misbehaving brand chain can't tie up a worker
// thread indefinitely.
const perSendTotalDeadline = 30 * time.Second
// Attempt is one provider attempt's result, recorded in the per-send
// telemetry. The provider id stays stable across schema migrations;
// success/duration/error are observable knobs the dashboards key on.
type Attempt struct {
Provider string `json:"provider"`
Started time.Time `json:"started"`
Duration time.Duration `json:"duration"`
Outcome string `json:"outcome"` // "ok" | "retryable" | "terminal"
Error string `json:"error,omitempty"`
}
// RunResult is the per-send outcome of executing a ProviderChain. On
// success, Winner is the provider that won (and Attempts ends with
// outcome="ok"). On exhausted-chain failure, Winner is empty and
// Attempts shows every provider that was tried.
type RunResult struct {
Channel ChainChannel `json:"channel"`
Tenant string `json:"tenant"`
Brand string `json:"brand"` // brand whose chain was used
Winner string `json:"winner,omitempty"`
Attempts []Attempt `json:"attempts"`
}
// ProviderChain is the ordered list of providers attempted for a single
// send. The first provider that succeeds wins; on retryable failure
// the next provider is attempted. On terminal failure (e.g. bad
// recipient, blocklist) we stop the chain.
type ProviderChain struct {
Channel ChainChannel
Tenant string
Brand string
Providers []ChainProvider
}
// ChainProvider is the narrow surface ProviderChain consumes per
// attempt. The notify library's Notifier covers Send; ID is metadata.
type ChainProvider interface {
ID() string
Send(ctx context.Context, subject, body string, to string) error
}
// RawSender is the optional capability a ChainProvider advertises when it
// can attach caller-supplied MIME headers to an email — the RFC 8058
// List-Unsubscribe / List-Unsubscribe-Post pair the marketing path needs.
// The plain Send surface cannot inject headers, so the marketing sender
// type-asserts to RawSender and falls back to Send when a provider does
// not implement it. Twilio's native Email API carries a headers map, so
// twilio_email is the implementer.
type RawSender interface {
ChainProvider
SendRaw(ctx context.Context, subject, body, to string, headers map[string]string) error
}
// Run walks the chain. It returns the winning provider id and the
// telemetry trace. If every provider fails it returns ErrChainExhausted
// with the trace attached.
//
// Retry policy (one and only one):
// - per-attempt context: a 10s deadline derived from the parent
// - per-attempt outcome:
// ok — the provider succeeded; return immediately
// terminal — the recipient is invalid; stop, no fallback
// retryable — anything else; advance to the next provider
// - whole-chain context: a 30s deadline ceiling
//
// Logger is optional; nil means no per-attempt log line is emitted.
// The structured Attempt list is always returned in RunResult.
func (c *ProviderChain) Run(ctx context.Context, to, subject, body string) (RunResult, error) {
res := RunResult{
Channel: c.Channel,
Tenant: c.Tenant,
Brand: c.Brand,
Attempts: make([]Attempt, 0, len(c.Providers)),
}
if len(c.Providers) == 0 {
return res, ErrNoProviders
}
outerCtx, cancel := context.WithTimeout(ctx, perSendTotalDeadline)
defer cancel()
var lastErr error
for _, p := range c.Providers {
// Respect the outer (whole-chain) deadline before paying any
// more provider cost.
if err := outerCtx.Err(); err != nil {
res.Attempts = append(res.Attempts, Attempt{
Provider: p.ID(),
Started: time.Now().UTC(),
Outcome: "retryable",
Error: "chain deadline exceeded before attempt",
})
lastErr = err
break
}
attemptCtx, attemptCancel := context.WithTimeout(outerCtx, perProviderAttemptTimeout)
started := time.Now().UTC()
err := p.Send(attemptCtx, subject, body, to)
dur := time.Since(started)
attemptCancel()
attempt := Attempt{
Provider: p.ID(),
Started: started,
Duration: dur,
}
switch {
case err == nil:
attempt.Outcome = "ok"
res.Attempts = append(res.Attempts, attempt)
res.Winner = p.ID()
return res, nil
case classifyError(err) == errTerminal:
attempt.Outcome = "terminal"
attempt.Error = err.Error()
res.Attempts = append(res.Attempts, attempt)
return res, &ChainError{
Result: res,
Cause: err,
Reason: "terminal",
}
default:
attempt.Outcome = "retryable"
attempt.Error = err.Error()
res.Attempts = append(res.Attempts, attempt)
lastErr = err
}
}
return res, &ChainError{
Result: res,
Cause: lastErr,
Reason: "exhausted",
}
}
// ErrNoProviders is returned when a ProviderChain has no providers to
// try (typically because the brand has not configured any provider for
// the channel and the default chain's providers all failed to construct
// due to missing KMS secrets). Distinct from ChainError so the caller
// can return 503 rather than 502.
var ErrNoProviders = errors.New("provider chain: no providers configured for channel/brand")
// ChainError wraps the underlying cause with the full attempt trace.
// Activities.Deliver records the cause on the Message row and emits the
// trace via the logger.
type ChainError struct {
Result RunResult
Cause error
Reason string // "exhausted" | "terminal"
}
func (e *ChainError) Error() string {
if e == nil {
return "<nil chain error>"
}
if e.Cause == nil {
return fmt.Sprintf("provider chain %s for tenant=%s channel=%s: %d attempts, no provider succeeded", e.Reason, e.Result.Tenant, e.Result.Channel, len(e.Result.Attempts))
}
return fmt.Sprintf("provider chain %s for tenant=%s channel=%s: %v (after %d attempts)", e.Reason, e.Result.Tenant, e.Result.Channel, e.Cause, len(e.Result.Attempts))
}
func (e *ChainError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// --- Error classification ---
type errClass int
const (
errRetryable errClass = iota
errTerminal
)
// classifyError decides whether an error from a provider should walk to
// the next chain entry (retryable) or short-circuit (terminal).
//
// Defaults: anything that smells like a 4xx client error, an invalid
// recipient, or a blocklist hit is terminal. Network errors, 5xx,
// timeouts, and unknown errors are retryable.
//
// We classify on the error message string because the upstream
// providers (Plivo, Twilio, AWS SES SDK, SendGrid) each surface
// non-portable typed errors. A substring match here is the smallest
// thing that works across all of them.
func classifyError(err error) errClass {
if err == nil {
return errRetryable
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return errRetryable
}
msg := strings.ToLower(err.Error())
for _, terminal := range []string{
"invalid number",
"invalid phone",
"invalid recipient",
"invalid 'to'",
"invalid email",
"blacklist",
"blocklist",
"suppressed",
"unsubscribed",
"do not contact",
"address rejected",
"address not verified",
"messagerejected",
"21211", // twilio: invalid 'to' number
"21610", // twilio: blocked recipient
"21612", // twilio: not reachable
"21614", // twilio: not mobile
"21617", // twilio: invalid messaging service sid
"30003", // twilio: unreachable
"30005", // twilio: unknown destination
"30007", // twilio: carrier filtered
"400 ",
"401 ",
"403 ",
"404 ",
"422 ",
"status 400",
"status 401",
"status 403",
"status 404",
"status 422",
"unauthorized",
"forbidden",
} {
if strings.Contains(msg, terminal) {
return errTerminal
}
}
return errRetryable
}
// --- ChainResolver: build a ProviderChain for (brand, channel) ---
// ChainResolver constructs ProviderChain instances for a (brand,
// channel) pair. It owns the KMS bridge — every constructed provider
// reads its secrets via the same fall-back path as PlivoResolver
// (brand/<slug>/<provider>/* → brand/hanzo/<provider>/* on miss).
type ChainResolver struct {
kms *kmsbridge.Client
// mu guards the per-channel order cache.
mu sync.RWMutex
cache map[string][]string // key: brand+"|"+channel
}
// NewChainResolver returns a ChainResolver bound to a KMS client. A nil
// kms is rejected — the resolver fail-closes at boot rather than at
// first send.
func NewChainResolver(kms *kmsbridge.Client) (*ChainResolver, error) {
if kms == nil {
return nil, errors.New("chain resolver: KMS client is required")
}
return &ChainResolver{
kms: kms,
cache: make(map[string][]string),
}, nil
}
// Resolve constructs a ProviderChain for the (brand, channel) pair.
// The order comes from KMS at brand/<slug>/notify-chain/<channel> as a
// JSON array of provider ids; absent → DefaultChainFor(channel). For
// each provider id in order, the resolver builds the provider with the
// brand's KMS secrets (falling back to brand/hanzo/<provider>/* on
// any missing secret), skipping providers the brand has not configured
// (and the Hanzo default has not configured either).
//
// The resolver returns ErrNoProviders if every provider in the resolved
// order failed to construct.
func (r *ChainResolver) Resolve(ctx context.Context, brand string, channel ChainChannel) (*ProviderChain, error) {
brand = strings.TrimSpace(brand)
if brand == "" {
return nil, errors.New("chain resolver: brand is required")
}
channel = canonicalChannel(string(channel))
if channel == "" {
return nil, errors.New("chain resolver: channel is required")
}
order := r.order(brand, channel)
chain := &ProviderChain{
Channel: channel,
Tenant: brand,
Brand: brand,
Providers: make([]ChainProvider, 0, len(order)),
}
for _, pid := range order {
p, brandUsed, err := r.buildProvider(ctx, brand, pid)
if err != nil {
// Treat construction failure as "this provider not available
// for this brand" and skip. We never want a missing fallback
// secret to break a brand that has a working primary.
continue
}
if brandUsed != brand && chain.Brand == brand {
// Record the fact that at least one provider fell back to
// the Hanzo default. Useful for telemetry but does not
// override the chain's nominal brand.
_ = brandUsed
}
chain.Providers = append(chain.Providers, p)
}
if len(chain.Providers) == 0 {
return chain, ErrNoProviders
}
return chain, nil
}
// order returns the resolved provider id order for (brand, channel).
// Per-tenant override at brand/<slug>/notify-chain/<channel> wins; on
// miss the function returns DefaultChainFor(channel).
//
// The per-call cost is one KMS read on cache miss. Results are cached
// for the process lifetime — chain reconfiguration takes a process
// bounce, which matches how the platform UI hands edits down today.
func (r *ChainResolver) order(brand string, channel ChainChannel) []string {
cacheKey := brand + "|" + string(channel)
r.mu.RLock()
if cached, ok := r.cache[cacheKey]; ok {
r.mu.RUnlock()
return cached
}
r.mu.RUnlock()
resolved := r.fetchOrder(brand, channel)
r.mu.Lock()
r.cache[cacheKey] = resolved
r.mu.Unlock()
return resolved
}
// InvalidateChainCache drops the cached chain order for (brand,
// channel). The platform UI calls this after writing a new chain so
// the next send picks up the change without a pod restart.
func (r *ChainResolver) InvalidateChainCache(brand string, channel ChainChannel) {
if r == nil {
return
}
r.mu.Lock()
delete(r.cache, brand+"|"+string(channel))
r.mu.Unlock()
}
// fetchOrder reads brand/<slug>/notify-chain/<channel> and returns the
// parsed JSON array, falling back to DefaultChainFor on miss / parse
// error / unknown ids. Unknown ids are filtered (not errored) so a bad
// override entry can never break sends — the chain just shortens.
func (r *ChainResolver) fetchOrder(brand string, channel ChainChannel) []string {
path := providerChainKMSPath + "/" + string(channel)
raw, err := r.kms.GetSecret(brand, "brand/"+brand+"/"+path)
if err != nil || strings.TrimSpace(raw) == "" {
// Try the Hanzo default's override before falling back to
// the hard-coded default chain.
if brand != DefaultBrand {
raw, err = r.kms.GetSecret(DefaultBrand, "brand/"+DefaultBrand+"/"+path)
}
if err != nil || strings.TrimSpace(raw) == "" {
return DefaultChainFor(channel)
}
}
var ids []string
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return DefaultChainFor(channel)
}
out := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.ToLower(strings.TrimSpace(id))
if !knownProvider(id) {
continue
}
out = append(out, id)
}
if len(out) == 0 {
return DefaultChainFor(channel)
}
return out
}
// knownProvider returns true for provider ids the resolver can build.
// New provider ids land here as a one-line edit when the matching
// buildProvider branch ships.
func knownProvider(id string) bool {
switch id {
case ProviderPlivo, ProviderTwilio, ProviderTwilioEmail,
ProviderSendGrid:
return true
}
return false
}
// buildProvider constructs a ChainProvider for (brand, providerID),
// resolving credentials via the brand → Hanzo-default fallback.
// The brand whose secrets were used is returned for telemetry.
func (r *ChainResolver) buildProvider(ctx context.Context, brand, providerID string) (ChainProvider, string, error) {
switch providerID {
case ProviderPlivo:
return r.buildPlivo(ctx, brand)
case ProviderTwilio:
return r.buildTwilio(ctx, brand)
case ProviderTwilioEmail:
return r.buildTwilioEmail(ctx, brand)
case ProviderSendGrid:
return r.buildSendGrid(ctx, brand)
}
return nil, "", fmt.Errorf("chain resolver: unknown provider %q", providerID)
}
// readBrandSecret implements the brand→default fallback for a single
// secret field. Returns the value + the brand whose KMS row supplied
// it. A missing brand value is silently fallen back to Hanzo; any
// other error short-circuits (so a transient KMS outage doesn't make
// us send under the wrong identity).
func (r *ChainResolver) readBrandSecret(brand, providerID, field string) (string, string, error) {
path := "brand/" + brand + "/" + providerID + "/" + field
val, err := r.kms.GetSecret(brand, path)
if err == nil {
return val, brand, nil
}
if !isMissingSecret(err) {
return "", "", fmt.Errorf("kms read %s/%s: %w", brand, path, err)
}
if brand == DefaultBrand {
return "", DefaultBrand, err
}
defaultPath := "brand/" + DefaultBrand + "/" + providerID + "/" + field
val, err = r.kms.GetSecret(DefaultBrand, defaultPath)
if err != nil {
return "", DefaultBrand, err
}
return val, DefaultBrand, nil
}
// readBrandSecretOptional behaves like readBrandSecret but treats a
// final miss as an empty string rather than an error. Used for fields
// that have sensible defaults (smtp-port, from-name) or are only
// required by some providers.
func (r *ChainResolver) readBrandSecretOptional(brand, providerID, field string) string {
v, _, _ := r.readBrandSecret(brand, providerID, field)
return v
}
func (r *ChainResolver) buildPlivo(_ context.Context, brand string) (ChainProvider, string, error) {
authID, b1, err := r.readBrandSecret(brand, ProviderPlivo, "auth-id")
if err != nil {
return nil, b1, fmt.Errorf("plivo: %w", err)
}
authToken, _, err := r.readBrandSecret(brand, ProviderPlivo, "auth-token")
if err != nil {
return nil, b1, fmt.Errorf("plivo: %w", err)
}
// sender-id-otp is preferred for OTP traffic; sender-id is the
// generic source. Either suffices.
senderID, _, err := r.readBrandSecret(brand, ProviderPlivo, "sender-id-otp")
if err != nil {
senderID, _, err = r.readBrandSecret(brand, ProviderPlivo, "sender-id")
if err != nil {
return nil, b1, fmt.Errorf("plivo: %w", err)
}
}
svc, err := plivo.New(
&plivo.ClientOptions{
AuthID: authID,
AuthToken: authToken,
HTTPClient: &http.Client{Timeout: perProviderAttemptTimeout},
},
&plivo.MessageOptions{Source: senderID},
)
if err != nil {
return nil, b1, fmt.Errorf("plivo construct: %w", err)
}
return &plivoChainProvider{svc: svc}, b1, nil
}
func (r *ChainResolver) buildTwilio(_ context.Context, brand string) (ChainProvider, string, error) {
accountSID, b1, err := r.readBrandSecret(brand, ProviderTwilio, "account-sid")
if err != nil {
return nil, b1, fmt.Errorf("twilio: %w", err)
}
authToken, _, err := r.readBrandSecret(brand, ProviderTwilio, "auth-token")
if err != nil {
return nil, b1, fmt.Errorf("twilio: %w", err)
}
from, _, err := r.readBrandSecret(brand, ProviderTwilio, "from-phone")
if err != nil {
// messaging-sid is a per-pool alternative to from-phone.
from, _, err = r.readBrandSecret(brand, ProviderTwilio, "messaging-sid")
if err != nil {
return nil, b1, fmt.Errorf("twilio: %w", err)
}
}
svc, err := twilio.New(accountSID, authToken, from)
if err != nil {
return nil, b1, fmt.Errorf("twilio construct: %w", err)
}
return &twilioChainProvider{svc: svc}, b1, nil
}
// buildTwilioEmail constructs the Twilio native Email provider. It reuses
// the SAME account credentials as Twilio SMS (brand/<slug>/twilio/
// {account-sid, auth-token}) and adds from-email — the verified sender on
// brand/<slug>/twilio/sender-domain (e.g. [email protected]). The
// optional from-name is the display name; absent → the from-email.
func (r *ChainResolver) buildTwilioEmail(_ context.Context, brand string) (ChainProvider, string, error) {
accountSID, b1, err := r.readBrandSecret(brand, ProviderTwilio, "account-sid")
if err != nil {
return nil, b1, fmt.Errorf("twilio_email: %w", err)
}
authToken, _, err := r.readBrandSecret(brand, ProviderTwilio, "auth-token")
if err != nil {
return nil, b1, fmt.Errorf("twilio_email: %w", err)
}
fromEmail, _, err := r.readBrandSecret(brand, ProviderTwilio, "from-email")
if err != nil {
return nil, b1, fmt.Errorf("twilio_email: %w", err)
}
fromName := r.readBrandSecretOptional(brand, ProviderTwilio, "from-name")
svc := twilioemail.New(accountSID, authToken, fromEmail, fromName)
return &twilioEmailChainProvider{svc: svc}, b1, nil
}
func (r *ChainResolver) buildSendGrid(_ context.Context, brand string) (ChainProvider, string, error) {
apiKey, b1, err := r.readBrandSecret(brand, ProviderSendGrid, "api-key")
if err != nil {
return nil, b1, fmt.Errorf("sendgrid: %w", err)
}
from, _, err := r.readBrandSecret(brand, ProviderSendGrid, "from-address")
if err != nil {
return nil, b1, fmt.Errorf("sendgrid: %w", err)
}
fromName := r.readBrandSecretOptional(brand, ProviderSendGrid, "from-name")
if fromName == "" {
fromName = from
}
svc := sendgrid.New(apiKey, from, fromName)
return &sendgridChainProvider{svc: svc}, b1, nil
}
// --- ChainProvider adapters ---
//
// Each adapter wraps one provider service from the notify library so it
// satisfies ChainProvider. The library expects AddReceivers + Send;
// here we always single-receiver so the chain stays per-recipient.
type plivoChainProvider struct {
svc *plivo.Service
}
func (p *plivoChainProvider) ID() string { return ProviderPlivo }
func (p *plivoChainProvider) Send(ctx context.Context, subject, body, to string) error {
// New svc per send keeps state local — AddReceivers is additive.
p.svc.AddReceivers(to)
return p.svc.Send(ctx, subject, body)
}
type twilioChainProvider struct {
svc *twilio.Service
}
func (p *twilioChainProvider) ID() string { return ProviderTwilio }
func (p *twilioChainProvider) Send(ctx context.Context, subject, body, to string) error {
p.svc.AddReceivers(to)
return p.svc.Send(ctx, subject, body)
}
type twilioEmailChainProvider struct {
svc *twilioemail.Service
}
func (p *twilioEmailChainProvider) ID() string { return ProviderTwilioEmail }
func (p *twilioEmailChainProvider) Send(ctx context.Context, subject, body, to string) error {
p.svc.AddReceivers(to)
return p.svc.Send(ctx, subject, body)
}
// SendRaw satisfies RawSender: Twilio's native Email API accepts a custom
// headers map, so List-Unsubscribe / List-Unsubscribe-Post ride along on
// the marketing path without leaving the Twilio account.
func (p *twilioEmailChainProvider) SendRaw(ctx context.Context, subject, body, to string, headers map[string]string) error {
p.svc.AddReceivers(to)
return p.svc.SendWithHeaders(ctx, subject, body, headers)
}
type sendgridChainProvider struct {
svc *sendgrid.SendGrid
}
func (p *sendgridChainProvider) ID() string { return ProviderSendGrid }
func (p *sendgridChainProvider) Send(ctx context.Context, subject, body, to string) error {
p.svc.AddReceivers(to)
return p.svc.Send(ctx, subject, body)
}
// Compile-time interface checks. Each adapter MUST satisfy
// ChainProvider; the notify.Notifier check is documentation that the
// wrapped service is a real Notifier and not a stub.
var (
_ ChainProvider = (*plivoChainProvider)(nil)
_ ChainProvider = (*twilioChainProvider)(nil)
_ ChainProvider = (*twilioEmailChainProvider)(nil)
_ ChainProvider = (*sendgridChainProvider)(nil)
// Twilio's native Email API is the only provider that exposes
// RawSender today — its JSON body carries a headers map, so the
// RFC 8058 List-Unsubscribe pair rides along on the marketing path.
_ RawSender = (*twilioEmailChainProvider)(nil)
_ notify.Notifier = (*plivo.Service)(nil)
_ notify.Notifier = (*twilio.Service)(nil)
_ notify.Notifier = (*twilioemail.Service)(nil)
_ notify.Notifier = (*sendgrid.SendGrid)(nil)
)