mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
* kmsbridge: drop legacy-Infisical workaround framing in doc comments
kmsbridge has been the canonical path to Hanzo KMS since the canonical
migration landed. Update doc comments to reflect that — the package is the
direct line to /v1/kms/orgs/{org}/secrets, not a stopgap around a base/plugins
version pin. The base.KMSClient surface compatibility note stays since callers
do still swap import paths to migrate.
* notify: SES SendRawEmail + List-Unsubscribe injection on marketing class
Per RFC 8058 + Gmail/Outlook bulk-sender policy (2024+), marketing email
must carry both List-Unsubscribe and List-Unsubscribe-Post to surface
the native one-click unsubscribe button. The SES SDK's SendEmail cannot
inject custom headers; SendRawEmail takes raw MIME instead.
Substrates:
- service/amazonses/raw.go: AmazonSES.SendRaw method, builds the MIME
envelope (From / To / Subject / Date / MIME-Version / Content-Type +
sorted extra headers + blank line + body) and ships via SendRawEmail.
Header smuggling (CR/LF in extra header values) is rejected at
compose time. SendEmail path is untouched.
- internal/marketing/category.go: IsMarketing predicate matching the
five §3.2 buckets (promotional, newsletter, product_update,
partner_offer, research) + HTML / plain footer injection helpers.
- internal/tasks/activities.go: marketing branch on Activities.send.
When (a) email channel, (b) marketing category, (c) marketing.Config
wired: mint unsubscribe token, persist row into
notification_unsubscribe_tokens BEFORE send, append visible footer
to the body, route through chain.RunMarketing.
- internal/tenant/chain.go: ChainProvider gains an optional RawSender
extension. RunMarketing prefers SendRaw when implemented (today: SES
API only), falls back to plain Send otherwise. Per-attempt + whole-
chain deadlines and the retry classifier are unchanged.
- pkg/types + internal/tasks/workflow: SendRequest / SendInput gain
Category, UserID, IsHTML fields.
- cmd/notifyd/main.go: wires marketing.Config (NOTIFY_ENV + unsubSigner)
onto Activities at boot. NOTIFY_UNSUBSCRIBE_SECRET also gates this
path (same fail-closed rule that already gates /unsubscribe routes).
Tests (37 added across 4 files):
- service/amazonses/raw_test.go — MIME envelope shape; sorted header
ordering; HTML vs plain Content-Type; multi-recipient To header;
header-smuggling rejection; SDK error propagation.
- internal/marketing/category_test.go — IsMarketing buckets + edges;
HTML + plain footer injection; empty URL no-op.
- internal/tasks/marketing_test.go — token persistence;
three-gate shouldUseMarketing decision matrix; idempotent re-persist;
extractToken URL parsing.
- internal/tenant/chain_marketing_test.go — RunMarketing prefers
RawSender path; falls back to Send when RawSender absent; multi-
provider fallthrough on retryable failure.
Completes the PR #7 follow-up: the header builder shipped in #7 is now
wired into the send path; one-click unsubscribe works end-to-end for
marketing-class email.
70 lines
2.0 KiB
Go
70 lines
2.0 KiB
Go
// Package boot wires the shared dependencies notifyd needs at startup.
|
|
//
|
|
// In particular the KMS client: notifyd reads per-tenant provider
|
|
// credentials out of Hanzo KMS via internal/kmsbridge — an HTTP client
|
|
// targeting canonical `/v1/kms/orgs/{org}/secrets/{path}/{name}` routes.
|
|
//
|
|
// Boot is intentionally tiny: one constructor + an env reader. The
|
|
// rest of the wiring lives in main.go.
|
|
package boot
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
|
|
"github.com/hanzoai/notify/internal/kmsbridge"
|
|
)
|
|
|
|
// KMSEndpoint returns the configured KMS endpoint or empty. Empty means
|
|
// "KMS disabled" — the resolver then falls back to env-var credentials.
|
|
func KMSEndpoint() string {
|
|
return envOr("KMS_ENDPOINT", "")
|
|
}
|
|
|
|
// IAMEndpoint returns the configured IAM endpoint or empty. Required
|
|
// for the IAM client_credentials grant unless KMS_AUTH_TOKEN is set.
|
|
func IAMEndpoint() string {
|
|
return envOr("IAM_ENDPOINT", "https://hanzo.id")
|
|
}
|
|
|
|
// KMSAuthToken returns the optional static bearer token for KMS. In
|
|
// production this stays empty and the bridge runs the IAM
|
|
// client_credentials exchange. Tests use a static token to
|
|
// short-circuit IAM.
|
|
func KMSAuthToken() string {
|
|
return os.Getenv("KMS_AUTH_TOKEN")
|
|
}
|
|
|
|
// NewKMSClient builds a kmsbridge.Client from env. Returns nil when
|
|
// KMS_ENDPOINT is unset — callers must handle that case (nil is the
|
|
// well-defined "no KMS" mode, not an error).
|
|
//
|
|
// Misconfiguration (e.g. KMS_ENDPOINT set but IAM_CLIENT_ID missing) is
|
|
// a fatal log; we want pods to crashloop loudly rather than silently
|
|
// run with broken KMS and fail every OTP send at request time.
|
|
func NewKMSClient() *kmsbridge.Client {
|
|
ep := KMSEndpoint()
|
|
if ep == "" {
|
|
return nil
|
|
}
|
|
cfg := kmsbridge.Config{
|
|
KMSEndpoint: ep,
|
|
IAMEndpoint: IAMEndpoint(),
|
|
ClientID: os.Getenv("IAM_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("IAM_CLIENT_SECRET"),
|
|
StaticBearer: KMSAuthToken(),
|
|
}
|
|
c, err := kmsbridge.New(cfg)
|
|
if err != nil {
|
|
log.Fatalf("boot: kmsbridge.New: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func envOr(k, d string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|